@anthusai/papyrus 2.0.0-next.69 → 2.0.0-next.70

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.
@@ -30,6 +30,7 @@ if _FUNCTION_DIRECTORY not in sys.path: # a Lambda already has its code directo
30
30
  sys.path.insert(0, _FUNCTION_DIRECTORY)
31
31
  import ai_moderation # noqa: E402
32
32
  import moderation as moderation_rules # noqa: E402
33
+ import proposals as proposal_rules # noqa: E402
33
34
 
34
35
  SITE_SUBJECT = re.compile(r"ss1_[A-Za-z0-9_-]{43}")
35
36
  INTERIM_SUBJECT = re.compile(r"ssdev_[A-Za-z0-9_-]{43}")
@@ -383,7 +384,9 @@ def my_state(store: ReaderStore, profile: dict[str, Any], payload: dict[str, Any
383
384
  for stored_id, value in found.items():
384
385
  subject, dimension = wanted[stored_id]
385
386
  votes.setdefault(subject, {})[dimension] = value
386
- return reply(200, {**public_profile(profile), "votes": votes})
387
+ # The reader's own up (1) or down (-1) vote per requested subject, by subject key (the key format of items.json).
388
+ subject_votes = {subject: held["up"] for subject, held in votes.items() if held.get("up") in (1, -1)}
389
+ return reply(200, {**public_profile(profile), "votes": votes, "subjectVotes": subject_votes})
387
390
 
388
391
 
389
392
  def set_handle(
@@ -976,6 +979,7 @@ class DynamoReaderStore:
976
979
  report_table: str = "",
977
980
  action_table: str = "",
978
981
  item_table: str = "",
982
+ proposal_table: str = "",
979
983
  flag_table: str = "",
980
984
  ) -> None:
981
985
  self._resource = resource
@@ -990,12 +994,45 @@ class DynamoReaderStore:
990
994
  "report": report_table,
991
995
  "action": action_table,
992
996
  "item": item_table,
997
+ "proposal": proposal_table,
993
998
  "flag": flag_table,
994
999
  }
995
1000
 
996
1001
  def _table(self, key: str) -> Any:
997
1002
  return self._resource.Table(self._names[key])
998
1003
 
1004
+ def get_proposal(self, proposal_id: str) -> dict[str, Any] | None:
1005
+ item = self._table("proposal").get_item(Key={"id": proposal_id}, ConsistentRead=True).get("Item")
1006
+ return plain(item) if item else None
1007
+
1008
+ def create_proposal(self, row: dict[str, Any]) -> bool:
1009
+ item = {**_without_none(row), "__typename": "Proposal", "updatedAt": row["createdAt"]}
1010
+ try:
1011
+ self._table("proposal").put_item(Item=item, ConditionExpression="attribute_not_exists(id)")
1012
+ except Exception as error:
1013
+ if is_conditional_failure(error):
1014
+ return False
1015
+ raise
1016
+ return True
1017
+
1018
+ def change_proposal(self, proposal_id: str, changes: dict[str, Any], expected_state: str, now_iso: str) -> bool:
1019
+ names = {"#st": "state", "#u": "updatedAt", **{f"#c{index}": key for index, key in enumerate(changes)}}
1020
+ assignments = ", ".join(f"#c{index} = :c{index}" for index in range(len(changes)))
1021
+ values = {":expected": expected_state, ":now": now_iso, **{f":c{index}": value for index, value in enumerate(changes.values())}}
1022
+ try:
1023
+ self._table("proposal").update_item(
1024
+ Key={"id": proposal_id},
1025
+ UpdateExpression=f"SET {assignments}, #u = :now",
1026
+ ConditionExpression="#st = :expected",
1027
+ ExpressionAttributeNames=names,
1028
+ ExpressionAttributeValues=values,
1029
+ )
1030
+ except Exception as error:
1031
+ if is_conditional_failure(error):
1032
+ return False
1033
+ raise
1034
+ return True
1035
+
999
1036
  def get_profile(self, site_subject: str) -> dict[str, Any] | None:
1000
1037
  item = self._table("profile").get_item(Key={"id": site_subject}, ConsistentRead=True).get("Item")
1001
1038
  return plain(item) if item else None
@@ -1409,6 +1446,7 @@ def store_from_environment(environment: dict[str, str] | None = None) -> DynamoR
1409
1446
  report_table=environment.get("PAPYRUS_READER_REPORT_TABLE", ""),
1410
1447
  action_table=environment.get("PAPYRUS_READER_ACTION_TABLE", ""),
1411
1448
  item_table=environment.get("PAPYRUS_READER_ITEM_TABLE", ""),
1449
+ proposal_table=environment.get("PAPYRUS_READER_PROPOSAL_TABLE", ""),
1412
1450
  flag_table=environment.get("PAPYRUS_READER_FLAG_TABLE", ""),
1413
1451
  )
1414
1452
 
@@ -1463,6 +1501,8 @@ def run_ai_worker(
1463
1501
 
1464
1502
  def handle_staff_event(event: dict[str, Any], *, store: Any, settings: ReaderSettings, now_seconds: float) -> dict[str, Any]:
1465
1503
  """``moderateReader`` from AppSync: an editor or admin hides, approves, promotes, bans or undoes. Answers a receipt, never an exception."""
1504
+ if (event.get("info") or {}).get("fieldName") in proposal_rules.MUTATIONS:
1505
+ return proposal_rules.handle_proposal_event(event, store, now_iso=iso(now_seconds))
1466
1506
  identity = event.get("identity") or {}
1467
1507
  claims = identity.get("claims") or {}
1468
1508
  groups = claims.get("cognito:groups") or identity.get("groups") or []
@@ -0,0 +1,255 @@
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 only; the function checks the groups again). Every state change is appended to the proposal's
7
+ ``history`` in the same conditional write that changes the state, so the history and the state cannot disagree.
8
+
9
+ Nothing here knows a person id. The proposer is ``"ai"`` or a reader's siteSubject, which is stored and never published; the
10
+ actor of a change is the staff member's Cognito subject, which is stored and never published either (the public file shows
11
+ "AI" or "Editor"). Votes are not counted here: they belong to the vote model and the snapshot joins them.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import re
18
+ import uuid
19
+ from dataclasses import dataclass
20
+ from typing import Any, Protocol
21
+
22
+ KINDS = ("story_draft", "new_concept", "rename", "merge", "split", "new_column", "element_symbol")
23
+ DRAFT, IN_STEERING, EDITOR_REVIEW, APPROVED, DECLINED, OVERRIDDEN_BY_EDITOR = (
24
+ "draft",
25
+ "in_steering",
26
+ "editor_review",
27
+ "approved",
28
+ "declined",
29
+ "overridden_by_editor",
30
+ )
31
+ STATES = (DRAFT, IN_STEERING, EDITOR_REVIEW, APPROVED, DECLINED, OVERRIDDEN_BY_EDITOR)
32
+
33
+ #: Every allowed state change. Anything not listed is refused. Approved, declined and overridden are final.
34
+ TRANSITIONS: dict[str, tuple[str, ...]] = {
35
+ DRAFT: (IN_STEERING, DECLINED),
36
+ IN_STEERING: (EDITOR_REVIEW,),
37
+ EDITOR_REVIEW: (APPROVED, DECLINED, OVERRIDDEN_BY_EDITOR),
38
+ }
39
+ #: The changes that need a reason. Every reason is public; this one is also required, because it explains a decision against the community.
40
+ REASON_REQUIRED = frozenset({OVERRIDDEN_BY_EDITOR})
41
+ #: An override says what the editor decided against the community's view: approved or declined. Approve and decline are their own result.
42
+ OVERRIDE_RESULTS = (APPROVED, DECLINED)
43
+
44
+ #: How many target subjects each kind names, as (minimum, maximum), and the types a target may have.
45
+ TARGET_COUNTS: dict[str, tuple[int, int]] = {
46
+ "story_draft": (0, 10),
47
+ "new_concept": (0, 10),
48
+ "rename": (1, 1),
49
+ "merge": (2, 10),
50
+ "split": (1, 1),
51
+ "new_column": (0, 10),
52
+ "element_symbol": (1, 1),
53
+ }
54
+ TARGET_TYPES = ("concept", "entity", "column", "story", "cluster")
55
+
56
+ MUTATIONS = ("fileProposal", "transitionProposal")
57
+ PROPOSER_AI = "ai"
58
+ STAFF_GROUPS = frozenset({"editor", "admin"})
59
+ PROPOSAL_ID = re.compile(r"prop-[0-9a-f]{32}")
60
+ TARGET_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:\-]{0,127}")
61
+ MAXIMUM_TITLE = 200
62
+ MAXIMUM_SUMMARY = 300
63
+ MAXIMUM_BODY = 50_000
64
+ MAXIMUM_REASON = 500
65
+ MAXIMUM_EVIDENCE_SOURCE = 200
66
+ MAXIMUM_EVIDENCE_SIGNALS = 1_000_000
67
+
68
+
69
+ @dataclass
70
+ class Outcome:
71
+ status: str # "ok" or "refused"
72
+ id: str | None = None
73
+ error: str | None = None
74
+ message: str | None = None
75
+
76
+ def to_dict(self) -> dict[str, Any]:
77
+ return {"status": self.status, "id": self.id, "error": self.error, "message": self.message}
78
+
79
+
80
+ def refused(error: str, message: str) -> Outcome:
81
+ return Outcome("refused", error=error, message=message)
82
+
83
+
84
+ class ProposalStore(Protocol):
85
+ def get_proposal(self, proposal_id: str) -> dict[str, Any] | None: ...
86
+
87
+ def create_proposal(self, row: dict[str, Any]) -> bool: ...
88
+
89
+ def change_proposal(self, proposal_id: str, changes: dict[str, Any], expected_state: str, now_iso: str) -> bool: ...
90
+
91
+
92
+ def staff_subject(identity: dict[str, Any]) -> str | None:
93
+ """The Cognito subject of an editor or admin, or ``None`` for anyone else."""
94
+ claims = identity.get("claims") or {}
95
+ groups = claims.get("cognito:groups") or identity.get("groups") or []
96
+ if isinstance(groups, str):
97
+ groups = [group.strip() for group in groups.strip("[]").replace(",", " ").split()]
98
+ subject = identity.get("sub")
99
+ if not isinstance(subject, str) or not subject or not STAFF_GROUPS.intersection(groups):
100
+ return None
101
+ return subject
102
+
103
+
104
+ def clean_text(value: Any, limit: int) -> str | None:
105
+ if not isinstance(value, str):
106
+ return None
107
+ text = value.strip()
108
+ return text if text and len(text) <= limit else None
109
+
110
+
111
+ def parse_json(value: Any) -> Any:
112
+ """AppSync delivers AWSJSON arguments as strings; the CLI and tests may pass the parsed value."""
113
+ if isinstance(value, str):
114
+ try:
115
+ return json.loads(value)
116
+ except ValueError:
117
+ return None
118
+ return value
119
+
120
+
121
+ def clean_targets(kind: str, raw: Any) -> list[dict[str, str]] | Outcome:
122
+ parsed = parse_json(raw) if raw is not None else []
123
+ if not isinstance(parsed, list):
124
+ return refused("invalid_targets", "targets must be a list of {type, id}.")
125
+ minimum, maximum = TARGET_COUNTS[kind]
126
+ targets: list[dict[str, str]] = []
127
+ for entry in parsed:
128
+ 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"]):
129
+ return refused("invalid_targets", f"Each target is {{type, id}} with a type of {', '.join(TARGET_TYPES)}.")
130
+ target = {"type": entry["type"], "id": entry["id"]}
131
+ if target in targets:
132
+ return refused("invalid_targets", "A target is named twice.")
133
+ targets.append(target)
134
+ if not minimum <= len(targets) <= maximum:
135
+ wanted = f"{minimum}" if minimum == maximum else f"{minimum} to {maximum}"
136
+ return refused("invalid_targets", f"A {kind} proposal names {wanted} target(s).")
137
+ return targets
138
+
139
+
140
+ def clean_evidence(raw: Any) -> dict[str, Any] | None | Outcome:
141
+ parsed = parse_json(raw) if raw is not None else None
142
+ if parsed is None:
143
+ return None
144
+ if not isinstance(parsed, dict):
145
+ return refused("invalid_evidence", "evidence must be {signals, source}.")
146
+ signals, source = parsed.get("signals"), parsed.get("source")
147
+ if not isinstance(signals, int) or isinstance(signals, bool) or not 0 <= signals <= MAXIMUM_EVIDENCE_SIGNALS:
148
+ return refused("invalid_evidence", "evidence.signals must be a whole number of signals.")
149
+ if source is not None and clean_text(source, MAXIMUM_EVIDENCE_SOURCE) is None:
150
+ return refused("invalid_evidence", "evidence.source must be short text.")
151
+ return {"signals": signals, "source": source.strip() if isinstance(source, str) else None}
152
+
153
+
154
+ 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]:
155
+ """The new row for a proposal in ``draft``, or the refusal. The proposer is AI: reader filing has no route yet."""
156
+ kind = arguments.get("kind")
157
+ if kind not in KINDS:
158
+ return None, refused("invalid_kind", f"kind must be one of {', '.join(KINDS)}.")
159
+ title = clean_text(arguments.get("title"), MAXIMUM_TITLE)
160
+ if title is None:
161
+ return None, refused("invalid_title", f"A proposal needs a title of at most {MAXIMUM_TITLE} characters.")
162
+ body = arguments.get("body") # Markus source, kept byte for byte
163
+ if not isinstance(body, str) or not body.strip() or len(body) > MAXIMUM_BODY:
164
+ return None, refused("invalid_body", "A proposal needs a body (Markus), at most 50,000 characters.")
165
+ summary = arguments.get("summary")
166
+ if summary is not None and clean_text(summary, MAXIMUM_SUMMARY) is None:
167
+ return None, refused("invalid_summary", f"summary is one line of at most {MAXIMUM_SUMMARY} characters.")
168
+ targets = clean_targets(kind, arguments.get("targets"))
169
+ if isinstance(targets, Outcome):
170
+ return None, targets
171
+ evidence = clean_evidence(arguments.get("evidence"))
172
+ if isinstance(evidence, Outcome):
173
+ return None, evidence
174
+ identifier = proposal_id or "prop-" + uuid.uuid4().hex
175
+ row: dict[str, Any] = {
176
+ "id": identifier,
177
+ "kind": kind,
178
+ "title": title,
179
+ "body": body,
180
+ "targets": json.dumps(targets),
181
+ "proposer": PROPOSER_AI,
182
+ "state": DRAFT,
183
+ "history": json.dumps([{"from": None, "to": DRAFT, "by": "ai", "actor": actor_sub, "reason": None, "at": now_iso}]),
184
+ "createdAt": now_iso,
185
+ }
186
+ if summary is not None:
187
+ row["summary"] = summary.strip()
188
+ if evidence is not None:
189
+ row["evidence"] = json.dumps(evidence)
190
+ return row, None
191
+
192
+
193
+ def transition_plan(row: dict[str, Any], to_state: Any, reason: Any, *, actor_sub: str, now_iso: str, result: Any = None) -> dict[str, Any] | Outcome:
194
+ """The changes that move ``row`` to ``to_state``, or why it may not move."""
195
+ state = row.get("state")
196
+ if to_state not in STATES:
197
+ return refused("invalid_state", f"state must be one of {', '.join(STATES)}.")
198
+ if to_state not in TRANSITIONS.get(state, ()):
199
+ return refused("transition_not_allowed", f"A proposal in {state} cannot move to {to_state}.")
200
+ text = None
201
+ if reason is not None and str(reason).strip():
202
+ text = clean_text(reason, MAXIMUM_REASON)
203
+ if text is None:
204
+ return refused("invalid_reason", f"A reason is plain text of at most {MAXIMUM_REASON} characters.")
205
+ if to_state in REASON_REQUIRED and text is None:
206
+ return refused("reason_required", "Overriding the community needs a public reason.")
207
+ if to_state == OVERRIDDEN_BY_EDITOR:
208
+ if result not in OVERRIDE_RESULTS:
209
+ return refused("result_required", f"An override says what the editor decided: {' or '.join(OVERRIDE_RESULTS)}.")
210
+ elif result is not None:
211
+ return refused("result_not_allowed", "Only an override carries a result; approve and decline are their own.")
212
+ entry = {"from": state, "to": to_state, "by": "staff", "actor": actor_sub, "reason": text, "at": now_iso}
213
+ if result is not None:
214
+ entry["result"] = result
215
+ history = [*(parse_json(row.get("history")) or []), entry]
216
+ return {"state": to_state, "history": json.dumps(history)}
217
+
218
+
219
+ def apply_transition(store: ProposalStore, *, proposal_id: Any, to_state: Any, reason: Any, actor_sub: str, now_iso: str, result: Any = None) -> Outcome:
220
+ if not isinstance(proposal_id, str) or not PROPOSAL_ID.fullmatch(proposal_id):
221
+ return refused("invalid_proposal", "That is not a proposal id.")
222
+ row = store.get_proposal(proposal_id)
223
+ if row is None:
224
+ return refused("not_found", "There is no such proposal.")
225
+ plan = transition_plan(row, to_state, reason, actor_sub=actor_sub, now_iso=now_iso, result=result)
226
+ if isinstance(plan, Outcome):
227
+ return plan
228
+ if not store.change_proposal(proposal_id, plan, row["state"], now_iso):
229
+ return refused("target_changed", "Someone else changed that proposal first.")
230
+ return Outcome("ok", id=proposal_id)
231
+
232
+
233
+ def handle_proposal_event(event: dict[str, Any], store: ProposalStore, *, now_iso: str) -> dict[str, Any]:
234
+ """``fileProposal`` or ``transitionProposal`` from AppSync. Answers a receipt, never an exception."""
235
+ actor = staff_subject(event.get("identity") or {})
236
+ if actor is None:
237
+ return refused("not_staff", "Only editors and admins can file or decide proposals.").to_dict()
238
+ arguments = event.get("arguments") or {}
239
+ field = (event.get("info") or {}).get("fieldName")
240
+ if field == "fileProposal":
241
+ row, problem = file_proposal(arguments, actor_sub=actor, now_iso=now_iso)
242
+ if problem is not None:
243
+ return problem.to_dict()
244
+ if not store.create_proposal(row):
245
+ return refused("exists", "That proposal already exists.").to_dict()
246
+ return Outcome("ok", id=row["id"]).to_dict()
247
+ return apply_transition(
248
+ store,
249
+ proposal_id=arguments.get("proposalId"),
250
+ to_state=arguments.get("state"),
251
+ reason=arguments.get("reason"),
252
+ result=arguments.get("result"),
253
+ actor_sub=actor,
254
+ now_iso=now_iso,
255
+ ).to_dict()
@@ -14,8 +14,8 @@ export const READER_POLL_SECRET_PARAMETER_ENV = "PAPYRUS_READER_POLL_SECRET_PARA
14
14
  export const readerSteeringManifest = {
15
15
  name: "reader-steering",
16
16
  contract: 1,
17
- models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction"],
18
- mutations: ["moderateReader"],
17
+ models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction", "Proposal"],
18
+ mutations: ["moderateReader", "fileProposal", "transitionProposal"],
19
19
  guest: { mutations: [] },
20
20
  newsroomPanels: [{ id: "moderation", label: "Moderation" }],
21
21
  staticAssets: ["_plugins/papyrus-reader@1.js", "_plugins/papyrus-reader@1.css"],
@@ -102,6 +102,7 @@ export const readerSteeringPlugin = {
102
102
  publicSettings: (config) => ({
103
103
  contract: readerSteeringManifest.contract,
104
104
  dimensions: config.dimensions,
105
+ subjects: { types: config.subjects.types },
105
106
  moderation: { mode: config.moderation.mode },
106
107
  }),
107
108
  wire(backend, config, context) {
@@ -117,6 +118,7 @@ export const readerSteeringPlugin = {
117
118
  ["PAPYRUS_READER_COMMENT_TABLE", "ReaderComment"],
118
119
  ["PAPYRUS_READER_REPORT_TABLE", "ReaderReport"],
119
120
  ["PAPYRUS_READER_ACTION_TABLE", "ModerationAction"],
121
+ ["PAPYRUS_READER_PROPOSAL_TABLE", "Proposal"],
120
122
  ]) {
121
123
  tables[model].grantReadWriteData(lambda);
122
124
  lambda.addEnvironment(variable, tables[model].tableName);
@@ -133,6 +135,8 @@ export const readerSteeringPlugin = {
133
135
  // The snapshot reads submissions, and the review step writes their signals and moderation state.
134
136
  tables.ReaderSubmission.grantReadWriteData(contentActions);
135
137
  tables.ReaderComment.grantReadWriteData(contentActions);
138
+ // The snapshot reads proposals and freezes the community result of one that reaches editor review.
139
+ tables.Proposal.grantReadWriteData(contentActions);
136
140
  lambda.addEnvironment("PAPYRUS_READER_SETTINGS", JSON.stringify(readerActionsSettings(config, interimSubjectsAllowedAtSynth())));
137
141
  if (config.reservedConcurrency !== undefined) {
138
142
  lambda.node.defaultChild.addPropertyOverride("ReservedConcurrentExecutions", config.reservedConcurrency);
@@ -21,8 +21,8 @@ export const READER_POLL_SECRET_PARAMETER_ENV = "PAPYRUS_READER_POLL_SECRET_PARA
21
21
  export const readerSteeringManifest: PluginManifest = {
22
22
  name: "reader-steering",
23
23
  contract: 1,
24
- models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction"],
25
- mutations: ["moderateReader"],
24
+ models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction", "Proposal"],
25
+ mutations: ["moderateReader", "fileProposal", "transitionProposal"],
26
26
  guest: { mutations: [] },
27
27
  newsroomPanels: [{ id: "moderation", label: "Moderation" }],
28
28
  staticAssets: ["_plugins/papyrus-reader@1.js", "_plugins/papyrus-reader@1.css"],
@@ -120,6 +120,7 @@ export const readerSteeringPlugin: PapyrusPlugin<ReaderSteeringConfig, { readerA
120
120
  publicSettings: (config) => ({
121
121
  contract: readerSteeringManifest.contract,
122
122
  dimensions: config.dimensions,
123
+ subjects: { types: config.subjects.types },
123
124
  moderation: { mode: config.moderation.mode },
124
125
  }),
125
126
  wire(backend, config, context: PluginWiringContext) {
@@ -136,6 +137,7 @@ export const readerSteeringPlugin: PapyrusPlugin<ReaderSteeringConfig, { readerA
136
137
  ["PAPYRUS_READER_COMMENT_TABLE", "ReaderComment"],
137
138
  ["PAPYRUS_READER_REPORT_TABLE", "ReaderReport"],
138
139
  ["PAPYRUS_READER_ACTION_TABLE", "ModerationAction"],
140
+ ["PAPYRUS_READER_PROPOSAL_TABLE", "Proposal"],
139
141
  ] as const) {
140
142
  tables[model].grantReadWriteData(lambda);
141
143
  lambda.addEnvironment(variable, tables[model].tableName);
@@ -152,6 +154,8 @@ export const readerSteeringPlugin: PapyrusPlugin<ReaderSteeringConfig, { readerA
152
154
  // The snapshot reads submissions, and the review step writes their signals and moderation state.
153
155
  tables.ReaderSubmission.grantReadWriteData(contentActions);
154
156
  tables.ReaderComment.grantReadWriteData(contentActions);
157
+ // The snapshot reads proposals and freezes the community result of one that reaches editor review.
158
+ tables.Proposal.grantReadWriteData(contentActions);
155
159
 
156
160
  lambda.addEnvironment("PAPYRUS_READER_SETTINGS", JSON.stringify(readerActionsSettings(config, interimSubjectsAllowedAtSynth())));
157
161
  if (config.reservedConcurrency !== undefined) {
@@ -134,6 +134,54 @@ export function readerSteeringSchemaDefinitions() {
134
134
  .disableOperations(["subscriptions"])
135
135
  .secondaryIndexes((index) => [index("target").sortKeys(["at"]).name("actionsByTarget").queryField("listModerationActionsByTargetAndAt")])
136
136
  .authorization(staffOnly),
137
+ Proposal: a
138
+ .model({
139
+ id: a.id().required(),
140
+ kind: a.string().required(),
141
+ title: a.string().required(),
142
+ summary: a.string(),
143
+ body: a.string().required(),
144
+ targets: a.json().required(),
145
+ evidence: a.json(),
146
+ proposer: a.string().required(),
147
+ state: a.string().required(),
148
+ history: a.json().required(),
149
+ communityResult: a.json(),
150
+ createdAt: a.datetime().required(),
151
+ })
152
+ .disableOperations(["subscriptions"])
153
+ .secondaryIndexes((index) => [index("state").sortKeys(["createdAt"]).name("proposalsByState").queryField("listProposalsByStateAndCreatedAt")])
154
+ .authorization(staffOnly),
155
+ ProposalReceipt: a.customType({
156
+ status: a.string().required(),
157
+ id: a.string(),
158
+ error: a.string(),
159
+ message: a.string(),
160
+ }),
161
+ fileProposal: a
162
+ .mutation()
163
+ .arguments({
164
+ kind: a.string().required(),
165
+ title: a.string().required(),
166
+ body: a.string().required(),
167
+ summary: a.string(),
168
+ targets: a.json(),
169
+ evidence: a.json(),
170
+ })
171
+ .returns(a.ref("ProposalReceipt"))
172
+ .authorization((allow) => [allow.group("editor"), allow.group("admin")])
173
+ .handler(a.handler.function(readerActions)),
174
+ transitionProposal: a
175
+ .mutation()
176
+ .arguments({
177
+ proposalId: a.string().required(),
178
+ state: a.string().required(),
179
+ reason: a.string(),
180
+ result: a.string(),
181
+ })
182
+ .returns(a.ref("ProposalReceipt"))
183
+ .authorization((allow) => [allow.group("editor"), allow.group("admin")])
184
+ .handler(a.handler.function(readerActions)),
137
185
  ModerationReceipt: a.customType({
138
186
  status: a.string().required(),
139
187
  id: a.string(),
@@ -136,6 +136,54 @@ export function readerSteeringSchemaDefinitions() {
136
136
  .disableOperations(["subscriptions"])
137
137
  .secondaryIndexes((index) => [index("target").sortKeys(["at"]).name("actionsByTarget").queryField("listModerationActionsByTargetAndAt")])
138
138
  .authorization(staffOnly),
139
+ Proposal: a
140
+ .model({
141
+ id: a.id().required(),
142
+ kind: a.string().required(),
143
+ title: a.string().required(),
144
+ summary: a.string(),
145
+ body: a.string().required(),
146
+ targets: a.json().required(),
147
+ evidence: a.json(),
148
+ proposer: a.string().required(),
149
+ state: a.string().required(),
150
+ history: a.json().required(),
151
+ communityResult: a.json(),
152
+ createdAt: a.datetime().required(),
153
+ })
154
+ .disableOperations(["subscriptions"])
155
+ .secondaryIndexes((index) => [index("state").sortKeys(["createdAt"]).name("proposalsByState").queryField("listProposalsByStateAndCreatedAt")])
156
+ .authorization(staffOnly),
157
+ ProposalReceipt: a.customType({
158
+ status: a.string().required(),
159
+ id: a.string(),
160
+ error: a.string(),
161
+ message: a.string(),
162
+ }),
163
+ fileProposal: a
164
+ .mutation()
165
+ .arguments({
166
+ kind: a.string().required(),
167
+ title: a.string().required(),
168
+ body: a.string().required(),
169
+ summary: a.string(),
170
+ targets: a.json(),
171
+ evidence: a.json(),
172
+ })
173
+ .returns(a.ref("ProposalReceipt"))
174
+ .authorization((allow) => [allow.group("editor"), allow.group("admin")])
175
+ .handler(a.handler.function(readerActions)),
176
+ transitionProposal: a
177
+ .mutation()
178
+ .arguments({
179
+ proposalId: a.string().required(),
180
+ state: a.string().required(),
181
+ reason: a.string(),
182
+ result: a.string(),
183
+ })
184
+ .returns(a.ref("ProposalReceipt"))
185
+ .authorization((allow) => [allow.group("editor"), allow.group("admin")])
186
+ .handler(a.handler.function(readerActions)),
139
187
  ModerationReceipt: a.customType({
140
188
  status: a.string().required(),
141
189
  id: a.string(),
package/package.json CHANGED
@@ -434,7 +434,7 @@
434
434
  "optional": true
435
435
  }
436
436
  },
437
- "version": "2.0.0-next.69",
437
+ "version": "2.0.0-next.70",
438
438
  "dependencies": {
439
439
  "@aws-amplify/adapter-nextjs": "^1.7.3",
440
440
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.0.0-next.69",
2
+ "version": "2.0.0-next.70",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",