@anthusai/papyrus 5.42.0-next.2 → 5.42.0-next.4

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 (36) hide show
  1. package/amplify/data/schema.js +2 -0
  2. package/amplify/data/schema.ts +2 -0
  3. package/amplify/functions/monitoring-view/schema.js +6 -0
  4. package/amplify/functions/monitoring-view/schema.ts +6 -0
  5. package/amplify/functions/monitoring-view/wire.js +1 -0
  6. package/amplify/functions/monitoring-view/wire.ts +1 -0
  7. package/amplify/functions/proposal-actions/handler.py +137 -0
  8. package/amplify/functions/proposal-actions/resource.js +15 -0
  9. package/amplify/functions/proposal-actions/resource.ts +22 -0
  10. package/amplify/functions/proposal-actions/schema.js +106 -0
  11. package/amplify/functions/proposal-actions/schema.ts +108 -0
  12. package/amplify/functions/proposal-actions/wire.js +10 -0
  13. package/amplify/functions/proposal-actions/wire.ts +13 -0
  14. package/amplify/plugins/reader-steering/function/handler.py +12 -69
  15. package/amplify/plugins/reader-steering/papyrus.plugin.js +5 -5
  16. package/amplify/plugins/reader-steering/papyrus.plugin.ts +5 -5
  17. package/amplify/plugins/reader-steering/schema.js +0 -95
  18. package/amplify/plugins/reader-steering/schema.ts +0 -95
  19. package/amplify/site-backend.js +4 -0
  20. package/amplify/site-backend.ts +4 -0
  21. package/app/globals.css +2 -35
  22. package/app/tailwind.css +2 -0
  23. package/components/monitoring-overview-view.tsx +7 -5
  24. package/components/monitoring-panel.tsx +33 -3
  25. package/components/newsroom-references-view.tsx +2 -3
  26. package/components/proposals-panel.tsx +110 -0
  27. package/components/proposals-view.tsx +93 -0
  28. package/components/topic-steering-workspace.tsx +13 -2
  29. package/lib/monitoring-client.ts +11 -2
  30. package/lib/monitoring-overview-fixture.ts +1 -1
  31. package/lib/monitoring-overview.ts +1 -1
  32. package/lib/proposals-admin.ts +115 -0
  33. package/lib/relevance-decisions.ts +9 -0
  34. package/package.json +4 -1
  35. package/routes.manifest.json +1 -1
  36. /package/amplify/{plugins/reader-steering/function → functions/proposal-actions}/proposals.py +0 -0
@@ -3,6 +3,7 @@ import { assignmentAction } from "../functions/assignment-action/resource.js";
3
3
  import { categoryAction } from "../functions/category-action/resource.js";
4
4
  import { editorialChecksSchemaDefinitions } from "../functions/editorial-checks/schema.js";
5
5
  import { automationViewSchemaDefinitions } from "../functions/automation-view/schema.js";
6
+ import { proposalActionsSchemaDefinitions } from "../functions/proposal-actions/schema.js";
6
7
  import { monitoringViewSchemaDefinitions } from "../functions/monitoring-view/schema.js";
7
8
  import { contentActions } from "../functions/content-actions/resource.js";
8
9
  import { knowledgeQuery } from "../functions/knowledge-query/resource.js";
@@ -2096,6 +2097,7 @@ function buildBaseDefinitions(includeEditions) {
2096
2097
  ...editorialChecksSchemaDefinitions,
2097
2098
  ...monitoringViewSchemaDefinitions,
2098
2099
  ...automationViewSchemaDefinitions,
2100
+ ...proposalActionsSchemaDefinitions,
2099
2101
  };
2100
2102
  if (!includeEditions) {
2101
2103
  const mutableDefinitions = definitions;
@@ -3,6 +3,7 @@ import { assignmentAction } from "../functions/assignment-action/resource";
3
3
  import { categoryAction } from "../functions/category-action/resource";
4
4
  import { editorialChecksSchemaDefinitions } from "../functions/editorial-checks/schema";
5
5
  import { automationViewSchemaDefinitions } from "../functions/automation-view/schema";
6
+ import { proposalActionsSchemaDefinitions } from "../functions/proposal-actions/schema";
6
7
  import { monitoringViewSchemaDefinitions } from "../functions/monitoring-view/schema";
7
8
  import { contentActions } from "../functions/content-actions/resource";
8
9
  import { knowledgeQuery } from "../functions/knowledge-query/resource";
@@ -2225,6 +2226,7 @@ const definitions = {
2225
2226
  ...editorialChecksSchemaDefinitions,
2226
2227
  ...monitoringViewSchemaDefinitions,
2227
2228
  ...automationViewSchemaDefinitions,
2229
+ ...proposalActionsSchemaDefinitions,
2228
2230
  };
2229
2231
  if (!includeEditions) {
2230
2232
  const mutableDefinitions = definitions as Record<string, unknown>;
@@ -20,6 +20,12 @@ export const monitoringViewSchemaDefinitions = {
20
20
  .returns(a.json())
21
21
  .authorization((allow) => [allow.groups(editors)])
22
22
  .handler(functionHandler("monitoringView", monitoringView)),
23
+ monitoringQueueEmailReference: a
24
+ .mutation()
25
+ .arguments({ input: a.json().required() })
26
+ .returns(a.json())
27
+ .authorization((allow) => [allow.groups(editors)])
28
+ .handler(functionHandler("monitoringView", monitoringView)),
23
29
  monitoringResolveEmailArticleLink: a
24
30
  .mutation()
25
31
  .arguments({ receiptId: a.string().required(), candidateIndex: a.integer().required() })
@@ -22,6 +22,12 @@ export const monitoringViewSchemaDefinitions = {
22
22
  .returns(a.json())
23
23
  .authorization((allow) => [allow.groups(editors)])
24
24
  .handler(functionHandler("monitoringView", monitoringView)),
25
+ monitoringQueueEmailReference: a
26
+ .mutation()
27
+ .arguments({ input: a.json().required() })
28
+ .returns(a.json())
29
+ .authorization((allow) => [allow.groups(editors)])
30
+ .handler(functionHandler("monitoringView", monitoringView)),
25
31
  monitoringResolveEmailArticleLink: a
26
32
  .mutation()
27
33
  .arguments({ receiptId: a.string().required(), candidateIndex: a.integer().required() })
@@ -22,6 +22,7 @@ export function monitoringViewParameterArn(region, account) {
22
22
  export function wireMonitoringView(backend, emailMonitorId = "hn") {
23
23
  const lambda = backend.monitoringView.resources.lambda;
24
24
  const stack = Stack.of(lambda);
25
+ lambda.addEnvironment("PAPYRUS_USER_POOL_ISSUER", `https://cognito-idp.${stack.region}.amazonaws.com/${backend.auth.resources.userPool.userPoolId}`);
25
26
  lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl);
26
27
  lambda.addEnvironment("PAPYRUS_MONITORING_TABLE_PARAMETER", MONITORING_TABLE_PARAMETER);
27
28
  lambda.addEnvironment("PAPYRUS_EMAIL_MONITOR_ID", emailMonitorId);
@@ -28,6 +28,7 @@ export function monitoringViewParameterArn(region: string, account: string): str
28
28
  export function wireMonitoringView(backend: any, emailMonitorId = "hn"): void {
29
29
  const lambda = backend.monitoringView.resources.lambda as LambdaFunction;
30
30
  const stack = Stack.of(lambda);
31
+ lambda.addEnvironment("PAPYRUS_USER_POOL_ISSUER", `https://cognito-idp.${stack.region}.amazonaws.com/${backend.auth.resources.userPool.userPoolId}`);
31
32
  lambda.addEnvironment("PAPYRUS_GRAPHQL_ENDPOINT", backend.data.resources.cfnResources.cfnGraphqlApi.attrGraphQlUrl as string);
32
33
  lambda.addEnvironment("PAPYRUS_MONITORING_TABLE_PARAMETER", MONITORING_TABLE_PARAMETER);
33
34
  lambda.addEnvironment("PAPYRUS_EMAIL_MONITOR_ID", emailMonitorId);
@@ -0,0 +1,137 @@
1
+ """proposal-actions: the Proposals queue every newsroom backend has (editors and operators only).
2
+
3
+ Behind ``fileProposal``, ``transitionProposal``, ``editProposal`` and ``listStoryDrafts``. The rules are the stdlib module
4
+ ``proposals.py`` beside this file; this file is the DynamoDB store and the AppSync entry point. The ``Proposal`` table is the
5
+ Amplify model table written directly, so editors can list it through the data API. Reader votes on proposals and the public
6
+ files belong to the reader-steering plugin, which reads this same table.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import sys
13
+ import time
14
+ from datetime import datetime, timezone
15
+ from decimal import Decimal
16
+ from typing import Any
17
+
18
+ _FUNCTION_DIRECTORY = os.path.dirname(os.path.abspath(__file__))
19
+ if _FUNCTION_DIRECTORY not in sys.path: # a Lambda already has its code directory on the path; a test loading this file by path does not
20
+ sys.path.insert(0, _FUNCTION_DIRECTORY)
21
+ import proposals as proposal_rules # noqa: E402
22
+
23
+ PROPOSAL_TABLE_ENV = "PAPYRUS_PROPOSAL_TABLE"
24
+
25
+
26
+ def iso(now_seconds: float) -> str:
27
+ """UTC with milliseconds, so rows written in the same second still sort in the order they were written."""
28
+ moment = datetime.fromtimestamp(now_seconds, tz=timezone.utc)
29
+ return moment.strftime("%Y-%m-%dT%H:%M:%S.") + f"{moment.microsecond // 1000:03d}Z"
30
+
31
+
32
+ def plain(value: Any) -> Any:
33
+ if isinstance(value, Decimal):
34
+ return int(value) if value == value.to_integral_value() else float(value)
35
+ if isinstance(value, dict):
36
+ return {key: plain(item) for key, item in value.items()}
37
+ if isinstance(value, list):
38
+ return [plain(item) for item in value]
39
+ return value
40
+
41
+
42
+ def _without_none(item: dict[str, Any]) -> dict[str, Any]:
43
+ return {key: value for key, value in item.items() if value is not None}
44
+
45
+
46
+ def is_conditional_failure(error: Exception) -> bool:
47
+ return str(getattr(error, "response", {}).get("Error", {}).get("Code", "")) == "ConditionalCheckFailedException"
48
+
49
+
50
+ class DynamoProposalStore:
51
+ """The ``Proposal`` model table written directly: items carry ``__typename`` and ``updatedAt`` so staff can list them."""
52
+
53
+ def __init__(self, *, resource: Any, proposal_table: str) -> None:
54
+ self._table = resource.Table(proposal_table)
55
+
56
+ def get_proposal(self, proposal_id: str) -> dict[str, Any] | None:
57
+ item = self._table.get_item(Key={"id": proposal_id}, ConsistentRead=True).get("Item")
58
+ return plain(item) if item else None
59
+
60
+ def create_proposal(self, row: dict[str, Any]) -> bool:
61
+ item = {**_without_none(row), "__typename": "Proposal", "updatedAt": row["createdAt"]}
62
+ try:
63
+ self._table.put_item(Item=item, ConditionExpression="attribute_not_exists(id)")
64
+ except Exception as error:
65
+ if is_conditional_failure(error):
66
+ return False
67
+ raise
68
+ return True
69
+
70
+ def list_story_drafts(self, limit: int) -> list[dict[str, Any]]:
71
+ """The story drafts of the table, only the fields ``listStoryDrafts`` may answer; at most ``limit`` (the newest if there are more)."""
72
+ fields = proposal_rules.STORY_DRAFT_FIELDS
73
+ names = {"#kind": "kind", **{f"#f{index}": field for index, field in enumerate(fields)}}
74
+ request: dict[str, Any] = {
75
+ "FilterExpression": "#kind = :kind",
76
+ "ProjectionExpression": ", ".join(f"#f{index}" for index in range(len(fields))) + ", #kind",
77
+ "ExpressionAttributeNames": names,
78
+ "ExpressionAttributeValues": {":kind": "story_draft"},
79
+ }
80
+ rows: list[dict[str, Any]] = []
81
+ while True:
82
+ page = self._table.scan(**request)
83
+ rows.extend(plain(item) for item in page.get("Items") or [])
84
+ if not page.get("LastEvaluatedKey"):
85
+ break
86
+ request["ExclusiveStartKey"] = page["LastEvaluatedKey"]
87
+ rows.sort(key=lambda row: (row.get("createdAt") or "", row.get("id") or ""), reverse=True)
88
+ return [{field: row.get(field) for field in fields} for row in rows[:limit]]
89
+
90
+ def change_proposal(self, proposal_id: str, changes: dict[str, Any], expected_state: str, now_iso: str, *, expected_updated_at: str | None = None) -> bool:
91
+ """Apply ``changes`` if the state (and, when given, ``updatedAt``) is still what the caller saw; a value of ``None`` removes the attribute."""
92
+ names = {"#st": "state", "#u": "updatedAt", **{f"#c{index}": key for index, key in enumerate(changes)}}
93
+ sets = ", ".join(f"#c{index} = :c{index}" for index, value in enumerate(changes.values()) if value is not None)
94
+ removes = ", ".join(f"#c{index}" for index, value in enumerate(changes.values()) if value is None)
95
+ values = {":expected": expected_state, ":now": now_iso, **{f":c{index}": value for index, value in enumerate(changes.values()) if value is not None}}
96
+ condition = "#st = :expected"
97
+ if expected_updated_at is not None:
98
+ condition += " AND #u = :seen"
99
+ values[":seen"] = expected_updated_at
100
+ expression = f"SET {sets + ', ' if sets else ''}#u = :now" + (f" REMOVE {removes}" if removes else "")
101
+ try:
102
+ self._table.update_item(
103
+ Key={"id": proposal_id},
104
+ UpdateExpression=expression,
105
+ ConditionExpression=condition,
106
+ ExpressionAttributeNames=names,
107
+ ExpressionAttributeValues=values,
108
+ )
109
+ except Exception as error:
110
+ if is_conditional_failure(error):
111
+ return False
112
+ raise
113
+ return True
114
+
115
+
116
+ def store_from_environment(environment: dict[str, str] | None = None) -> DynamoProposalStore:
117
+ import boto3
118
+
119
+ environment = os.environ if environment is None else environment
120
+ return DynamoProposalStore(resource=boto3.resource("dynamodb"), proposal_table=environment[PROPOSAL_TABLE_ENV])
121
+
122
+
123
+ def function_account(context: Any) -> str | None:
124
+ """The AWS account this function runs in, from its own ARN: the only account whose IAM operators may file proposals."""
125
+ parts = str(getattr(context, "invoked_function_arn", None) or "").split(":")
126
+ return parts[4] if len(parts) > 4 and parts[4] else None
127
+
128
+
129
+ def handle_event(event: dict[str, Any], *, store: Any, now_seconds: float, account_id: str | None = None) -> dict[str, Any]:
130
+ """Route an AppSync invocation to the proposal rules. Answers a receipt, never an exception."""
131
+ if proposal_rules.event_field_name(event) in proposal_rules.QUERIES:
132
+ return proposal_rules.handle_proposal_query(event, store, account_id=account_id)
133
+ return proposal_rules.handle_proposal_event(event, store, now_iso=iso(now_seconds), account_id=account_id)
134
+
135
+
136
+ def handler(event: dict[str, Any], _context: Any = None) -> dict[str, Any]:
137
+ return handle_event(event, store=store_from_environment(), now_seconds=time.time(), account_id=function_account(_context))
@@ -0,0 +1,15 @@
1
+ import { defineFunction } from "@aws-amplify/backend";
2
+ import { Duration } from "aws-cdk-lib";
3
+ import { Architecture, Code, Function, Runtime } from "aws-cdk-lib/aws-lambda";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ const functionDirectory = path.dirname(fileURLToPath(import.meta.url));
7
+ /** stdlib plus the runtime's boto3: the asset is this directory's two Python files and nothing else. */
8
+ export const proposalActions = defineFunction((scope) => new Function(scope, "papyrus-proposal-actions", {
9
+ runtime: Runtime.PYTHON_3_12,
10
+ architecture: Architecture.ARM_64,
11
+ handler: "handler.handler",
12
+ timeout: Duration.seconds(25),
13
+ memorySize: 256,
14
+ code: Code.fromAsset(functionDirectory, { exclude: ["*.ts", "__pycache__", "*.pyc"] }),
15
+ }), { resourceGroupName: "data" });
@@ -0,0 +1,22 @@
1
+ import { defineFunction } from "@aws-amplify/backend";
2
+ import { Duration } from "aws-cdk-lib";
3
+ import { Architecture, Code, Function, Runtime } from "aws-cdk-lib/aws-lambda";
4
+ import type { Construct } from "constructs";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const functionDirectory = path.dirname(fileURLToPath(import.meta.url));
9
+
10
+ /** stdlib plus the runtime's boto3: the asset is this directory's two Python files and nothing else. */
11
+ export const proposalActions = defineFunction(
12
+ (scope: Construct) =>
13
+ new Function(scope, "papyrus-proposal-actions", {
14
+ runtime: Runtime.PYTHON_3_12,
15
+ architecture: Architecture.ARM_64,
16
+ handler: "handler.handler",
17
+ timeout: Duration.seconds(25),
18
+ memorySize: 256,
19
+ code: Code.fromAsset(functionDirectory, { exclude: ["*.ts", "__pycache__", "*.pyc"] }),
20
+ }),
21
+ { resourceGroupName: "data" },
22
+ );
@@ -0,0 +1,106 @@
1
+ import { a } from "@aws-amplify/backend";
2
+ import { proposalActions } from "./resource.js";
3
+ import { functionHandler } from "../../data/function-handler.js";
4
+ /**
5
+ * Core to every site's data API (editors and operators only; nothing here is reader-facing). Editors may list proposals;
6
+ * admins may also delete. The proposal-actions function writes the table with its own role. The reader-steering plugin
7
+ * builds on this model: it reads the table to take reader votes on proposals and to publish them in its snapshot.
8
+ */
9
+ const staffOnly = (allow) => [allow.group("editor").to(["read"]), allow.group("admin").to(["read", "delete"])];
10
+ export const proposalActionsSchemaDefinitions = {
11
+ Proposal: a
12
+ .model({
13
+ id: a.id().required(),
14
+ kind: a.string().required(),
15
+ title: a.string().required(),
16
+ summary: a.string(),
17
+ body: a.string().required(),
18
+ targets: a.json().required(),
19
+ evidence: a.json(),
20
+ proposer: a.string().required(),
21
+ state: a.string().required(),
22
+ history: a.json().required(),
23
+ communityResult: a.json(),
24
+ pinned: a.boolean(),
25
+ held: a.boolean(),
26
+ override: a.json(),
27
+ exclusions: a.json(),
28
+ editorLog: a.json(),
29
+ needsLook: a.json(),
30
+ createdAt: a.datetime().required(),
31
+ })
32
+ .disableOperations(["subscriptions"])
33
+ .secondaryIndexes((index) => [index("state").sortKeys(["createdAt"]).name("proposalsByState").queryField("listProposalsByStateAndCreatedAt")])
34
+ .authorization(staffOnly),
35
+ ProposalReceipt: a.customType({
36
+ status: a.string().required(),
37
+ id: a.string(),
38
+ error: a.string(),
39
+ message: a.string(),
40
+ }),
41
+ // What an operator may read of a story draft: these six fields and nothing else (no body, evidence, history, proposer, vote or log).
42
+ StoryDraftSummary: a.customType({
43
+ id: a.string().required(),
44
+ title: a.string().required(),
45
+ summary: a.string(),
46
+ targets: a.json(),
47
+ state: a.string().required(),
48
+ createdAt: a.string().required(),
49
+ }),
50
+ StoryDraftList: a.customType({
51
+ status: a.string().required(),
52
+ id: a.string(),
53
+ error: a.string(),
54
+ message: a.string(),
55
+ drafts: a.ref("StoryDraftSummary").array(),
56
+ }),
57
+ // Editors and admins file as themselves. AppSync also admits an IAM principal whose own policy allows the field (the
58
+ // schema accepts IAM callers for the functions that call it); the function files an AI proposal for an IAM principal
59
+ // of its own account only, and refuses an identity-pool user and any other account. No IAM rule is declared here.
60
+ fileProposal: a
61
+ .mutation()
62
+ .arguments({
63
+ kind: a.string().required(),
64
+ title: a.string().required(),
65
+ body: a.string().required(),
66
+ summary: a.string(),
67
+ targets: a.json(),
68
+ evidence: a.json(),
69
+ })
70
+ .returns(a.ref("ProposalReceipt"))
71
+ .authorization((allow) => [allow.group("editor"), allow.group("admin")])
72
+ .handler(functionHandler("proposalActions", proposalActions)),
73
+ // The same IAM rule as fileProposal, narrower: an operator may move only draft to in_steering or declined.
74
+ // Approving, deciding after review and overriding are refused for IAM in the function (`operator_not_allowed`).
75
+ transitionProposal: a
76
+ .mutation()
77
+ .arguments({
78
+ proposalId: a.string().required(),
79
+ state: a.string().required(),
80
+ reason: a.string(),
81
+ result: a.string(),
82
+ })
83
+ .returns(a.ref("ProposalReceipt"))
84
+ .authorization((allow) => [allow.group("editor"), allow.group("admin")])
85
+ .handler(functionHandler("proposalActions", proposalActions)),
86
+ // Read only, and the same caller rule as fileProposal: editors and admins, or an IAM principal of the function's own account. The function
87
+ // answers the story drafts' id, title, summary, targets, state and createdAt so an unattended run can dedupe; nothing else.
88
+ listStoryDrafts: a
89
+ .query()
90
+ .returns(a.ref("StoryDraftList"))
91
+ .authorization((allow) => [allow.group("editor"), allow.group("admin")])
92
+ .handler(functionHandler("proposalActions", proposalActions)),
93
+ // Editors and admins only, and the function refuses an IAM operator: pin, unpin, hold, release, exclude_votes and
94
+ // undo_exclusion. `rule` is a batch rule for exclude_votes; `exclusionId` names the exclusion undo_exclusion reverses.
95
+ editProposal: a
96
+ .mutation()
97
+ .arguments({
98
+ proposalId: a.string().required(),
99
+ action: a.string().required(),
100
+ rule: a.string(),
101
+ exclusionId: a.string(),
102
+ })
103
+ .returns(a.ref("ProposalReceipt"))
104
+ .authorization((allow) => [allow.group("editor"), allow.group("admin")])
105
+ .handler(functionHandler("proposalActions", proposalActions)),
106
+ };
@@ -0,0 +1,108 @@
1
+ import { a } from "@aws-amplify/backend";
2
+ import { proposalActions } from "./resource";
3
+ import { functionHandler } from "../../data/function-handler";
4
+
5
+ /**
6
+ * Core to every site's data API (editors and operators only; nothing here is reader-facing). Editors may list proposals;
7
+ * admins may also delete. The proposal-actions function writes the table with its own role. The reader-steering plugin
8
+ * builds on this model: it reads the table to take reader votes on proposals and to publish them in its snapshot.
9
+ */
10
+ const staffOnly = (allow: any) => [allow.group("editor").to(["read"]), allow.group("admin").to(["read", "delete"])];
11
+
12
+ export const proposalActionsSchemaDefinitions = {
13
+ Proposal: a
14
+ .model({
15
+ id: a.id().required(),
16
+ kind: a.string().required(),
17
+ title: a.string().required(),
18
+ summary: a.string(),
19
+ body: a.string().required(),
20
+ targets: a.json().required(),
21
+ evidence: a.json(),
22
+ proposer: a.string().required(),
23
+ state: a.string().required(),
24
+ history: a.json().required(),
25
+ communityResult: a.json(),
26
+ pinned: a.boolean(),
27
+ held: a.boolean(),
28
+ override: a.json(),
29
+ exclusions: a.json(),
30
+ editorLog: a.json(),
31
+ needsLook: a.json(),
32
+ createdAt: a.datetime().required(),
33
+ })
34
+ .disableOperations(["subscriptions"])
35
+ .secondaryIndexes((index) => [index("state").sortKeys(["createdAt"]).name("proposalsByState").queryField("listProposalsByStateAndCreatedAt")])
36
+ .authorization(staffOnly),
37
+ ProposalReceipt: a.customType({
38
+ status: a.string().required(),
39
+ id: a.string(),
40
+ error: a.string(),
41
+ message: a.string(),
42
+ }),
43
+ // What an operator may read of a story draft: these six fields and nothing else (no body, evidence, history, proposer, vote or log).
44
+ StoryDraftSummary: a.customType({
45
+ id: a.string().required(),
46
+ title: a.string().required(),
47
+ summary: a.string(),
48
+ targets: a.json(),
49
+ state: a.string().required(),
50
+ createdAt: a.string().required(),
51
+ }),
52
+ StoryDraftList: a.customType({
53
+ status: a.string().required(),
54
+ id: a.string(),
55
+ error: a.string(),
56
+ message: a.string(),
57
+ drafts: a.ref("StoryDraftSummary").array(),
58
+ }),
59
+ // Editors and admins file as themselves. AppSync also admits an IAM principal whose own policy allows the field (the
60
+ // schema accepts IAM callers for the functions that call it); the function files an AI proposal for an IAM principal
61
+ // of its own account only, and refuses an identity-pool user and any other account. No IAM rule is declared here.
62
+ fileProposal: a
63
+ .mutation()
64
+ .arguments({
65
+ kind: a.string().required(),
66
+ title: a.string().required(),
67
+ body: a.string().required(),
68
+ summary: a.string(),
69
+ targets: a.json(),
70
+ evidence: a.json(),
71
+ })
72
+ .returns(a.ref("ProposalReceipt"))
73
+ .authorization((allow) => [allow.group("editor"), allow.group("admin")])
74
+ .handler(functionHandler("proposalActions", proposalActions)),
75
+ // The same IAM rule as fileProposal, narrower: an operator may move only draft to in_steering or declined.
76
+ // Approving, deciding after review and overriding are refused for IAM in the function (`operator_not_allowed`).
77
+ transitionProposal: a
78
+ .mutation()
79
+ .arguments({
80
+ proposalId: a.string().required(),
81
+ state: a.string().required(),
82
+ reason: a.string(),
83
+ result: a.string(),
84
+ })
85
+ .returns(a.ref("ProposalReceipt"))
86
+ .authorization((allow) => [allow.group("editor"), allow.group("admin")])
87
+ .handler(functionHandler("proposalActions", proposalActions)),
88
+ // Read only, and the same caller rule as fileProposal: editors and admins, or an IAM principal of the function's own account. The function
89
+ // answers the story drafts' id, title, summary, targets, state and createdAt so an unattended run can dedupe; nothing else.
90
+ listStoryDrafts: a
91
+ .query()
92
+ .returns(a.ref("StoryDraftList"))
93
+ .authorization((allow) => [allow.group("editor"), allow.group("admin")])
94
+ .handler(functionHandler("proposalActions", proposalActions)),
95
+ // Editors and admins only, and the function refuses an IAM operator: pin, unpin, hold, release, exclude_votes and
96
+ // undo_exclusion. `rule` is a batch rule for exclude_votes; `exclusionId` names the exclusion undo_exclusion reverses.
97
+ editProposal: a
98
+ .mutation()
99
+ .arguments({
100
+ proposalId: a.string().required(),
101
+ action: a.string().required(),
102
+ rule: a.string(),
103
+ exclusionId: a.string(),
104
+ })
105
+ .returns(a.ref("ProposalReceipt"))
106
+ .authorization((allow) => [allow.group("editor"), allow.group("admin")])
107
+ .handler(functionHandler("proposalActions", proposalActions)),
108
+ };
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Least-privilege wiring for proposal-actions: read and write on the Proposal table and nothing else, with the table's
3
+ * name in `PAPYRUS_PROPOSAL_TABLE`. Every site gets this, with or without the reader-steering plugin.
4
+ */
5
+ export function wireProposalActions(backend) {
6
+ const lambda = backend.proposalActions.resources.lambda;
7
+ const table = backend.data.resources.tables.Proposal;
8
+ table.grantReadWriteData(lambda);
9
+ lambda.addEnvironment("PAPYRUS_PROPOSAL_TABLE", table.tableName);
10
+ }
@@ -0,0 +1,13 @@
1
+ import type { ITable } from "aws-cdk-lib/aws-dynamodb";
2
+ import type { Function as LambdaFunction } from "aws-cdk-lib/aws-lambda";
3
+
4
+ /**
5
+ * Least-privilege wiring for proposal-actions: read and write on the Proposal table and nothing else, with the table's
6
+ * name in `PAPYRUS_PROPOSAL_TABLE`. Every site gets this, with or without the reader-steering plugin.
7
+ */
8
+ export function wireProposalActions(backend: any): void {
9
+ const lambda = backend.proposalActions.resources.lambda as LambdaFunction;
10
+ const table = (backend.data.resources.tables as Record<string, ITable>).Proposal;
11
+ table.grantReadWriteData(lambda);
12
+ lambda.addEnvironment("PAPYRUS_PROPOSAL_TABLE", table.tableName);
13
+ }
@@ -30,7 +30,6 @@ 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
34
33
 
35
34
  SITE_SUBJECT = re.compile(r"ss1_[A-Za-z0-9_-]{43}")
36
35
  INTERIM_SUBJECT = re.compile(r"ssdev_[A-Za-z0-9_-]{43}")
@@ -979,7 +978,6 @@ class DynamoReaderStore:
979
978
  report_table: str = "",
980
979
  action_table: str = "",
981
980
  item_table: str = "",
982
- proposal_table: str = "",
983
981
  flag_table: str = "",
984
982
  ) -> None:
985
983
  self._resource = resource
@@ -994,72 +992,12 @@ class DynamoReaderStore:
994
992
  "report": report_table,
995
993
  "action": action_table,
996
994
  "item": item_table,
997
- "proposal": proposal_table,
998
995
  "flag": flag_table,
999
996
  }
1000
997
 
1001
998
  def _table(self, key: str) -> Any:
1002
999
  return self._resource.Table(self._names[key])
1003
1000
 
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 list_story_drafts(self, limit: int) -> list[dict[str, Any]]:
1019
- """The story drafts of the table, only the fields ``listStoryDrafts`` may answer; at most ``limit`` (the newest if there are more)."""
1020
- fields = proposal_rules.STORY_DRAFT_FIELDS
1021
- names = {"#kind": "kind", **{f"#f{index}": field for index, field in enumerate(fields)}}
1022
- request: dict[str, Any] = {
1023
- "FilterExpression": "#kind = :kind",
1024
- "ProjectionExpression": ", ".join(f"#f{index}" for index in range(len(fields))) + ", #kind",
1025
- "ExpressionAttributeNames": names,
1026
- "ExpressionAttributeValues": {":kind": "story_draft"},
1027
- }
1028
- rows: list[dict[str, Any]] = []
1029
- while True:
1030
- page = self._table("proposal").scan(**request)
1031
- rows.extend(plain(item) for item in page.get("Items") or [])
1032
- if not page.get("LastEvaluatedKey"):
1033
- break
1034
- request["ExclusiveStartKey"] = page["LastEvaluatedKey"]
1035
- rows.sort(key=lambda row: (row.get("createdAt") or "", row.get("id") or ""), reverse=True)
1036
- return [{field: row.get(field) for field in fields} for row in rows[:limit]]
1037
-
1038
- def change_proposal(self, proposal_id: str, changes: dict[str, Any], expected_state: str, now_iso: str, *, expected_updated_at: str | None = None) -> bool:
1039
- """Apply ``changes`` if the state (and, when given, ``updatedAt``) is still what the caller saw; a value of ``None`` removes the attribute."""
1040
- names = {"#st": "state", "#u": "updatedAt", **{f"#c{index}": key for index, key in enumerate(changes)}}
1041
- sets = ", ".join(f"#c{index} = :c{index}" for index, value in enumerate(changes.values()) if value is not None)
1042
- removes = ", ".join(f"#c{index}" for index, value in enumerate(changes.values()) if value is None)
1043
- values = {":expected": expected_state, ":now": now_iso, **{f":c{index}": value for index, value in enumerate(changes.values()) if value is not None}}
1044
- condition = "#st = :expected"
1045
- if expected_updated_at is not None:
1046
- condition += " AND #u = :seen"
1047
- values[":seen"] = expected_updated_at
1048
- expression = f"SET {sets + ', ' if sets else ''}#u = :now" + (f" REMOVE {removes}" if removes else "")
1049
- try:
1050
- self._table("proposal").update_item(
1051
- Key={"id": proposal_id},
1052
- UpdateExpression=expression,
1053
- ConditionExpression=condition,
1054
- ExpressionAttributeNames=names,
1055
- ExpressionAttributeValues=values,
1056
- )
1057
- except Exception as error:
1058
- if is_conditional_failure(error):
1059
- return False
1060
- raise
1061
- return True
1062
-
1063
1001
  def get_profile(self, site_subject: str) -> dict[str, Any] | None:
1064
1002
  item = self._table("profile").get_item(Key={"id": site_subject}, ConsistentRead=True).get("Item")
1065
1003
  return plain(item) if item else None
@@ -1473,7 +1411,6 @@ def store_from_environment(environment: dict[str, str] | None = None) -> DynamoR
1473
1411
  report_table=environment.get("PAPYRUS_READER_REPORT_TABLE", ""),
1474
1412
  action_table=environment.get("PAPYRUS_READER_ACTION_TABLE", ""),
1475
1413
  item_table=environment.get("PAPYRUS_READER_ITEM_TABLE", ""),
1476
- proposal_table=environment.get("PAPYRUS_READER_PROPOSAL_TABLE", ""),
1477
1414
  flag_table=environment.get("PAPYRUS_READER_FLAG_TABLE", ""),
1478
1415
  )
1479
1416
 
@@ -1528,10 +1465,6 @@ def run_ai_worker(
1528
1465
 
1529
1466
  def handle_staff_event(event: dict[str, Any], *, store: Any, settings: ReaderSettings, now_seconds: float, account_id: str | None = None) -> dict[str, Any]:
1530
1467
  """``moderateReader`` from AppSync: an editor or admin hides, approves, promotes, bans or undoes. Answers a receipt, never an exception."""
1531
- if proposal_rules.event_field_name(event) in proposal_rules.MUTATIONS:
1532
- return proposal_rules.handle_proposal_event(event, store, now_iso=iso(now_seconds), account_id=account_id)
1533
- if proposal_rules.event_field_name(event) in proposal_rules.QUERIES:
1534
- return proposal_rules.handle_proposal_query(event, store, account_id=account_id)
1535
1468
  identity = event.get("identity") or {}
1536
1469
  claims = identity.get("claims") or {}
1537
1470
  groups = claims.get("cognito:groups") or identity.get("groups") or []
@@ -1556,8 +1489,18 @@ def handle_staff_event(event: dict[str, Any], *, store: Any, settings: ReaderSet
1556
1489
  return outcome.to_dict()
1557
1490
 
1558
1491
 
1492
+ def is_appsync_event(event: dict[str, Any]) -> bool:
1493
+ """True for an AppSync invocation (arguments plus ``info`` or a top-level ``fieldName``); the public path sends siteSubject, action and payload.
1494
+
1495
+ The Amplify Gen 2 Lambda data source sends ``fieldName`` at the top level with no ``info`` key; a direct resolver nests it under ``info``.
1496
+ """
1497
+ info = event.get("info")
1498
+ name = (info.get("fieldName") if isinstance(info, dict) else None) or event.get("fieldName")
1499
+ return "arguments" in event and ("info" in event or (isinstance(name, str) and bool(name)))
1500
+
1501
+
1559
1502
  def function_account(context: Any) -> str | None:
1560
- """The AWS account this function runs in, from its own ARN: the only account whose IAM operators may file proposals."""
1503
+ """The AWS account this function runs in, from its own ARN: the account of the function."""
1561
1504
  parts = str(getattr(context, "invoked_function_arn", None) or "").split(":")
1562
1505
  return parts[4] if len(parts) > 4 and parts[4] else None
1563
1506
 
@@ -1568,7 +1511,7 @@ def handler(event: dict[str, Any], _context: Any = None) -> dict[str, Any]:
1568
1511
  settings = settings_from_environment()
1569
1512
  store = store_from_environment()
1570
1513
  now_seconds = time.time()
1571
- if proposal_rules.is_appsync_event(event):
1514
+ if is_appsync_event(event):
1572
1515
  return handle_staff_event(event, store=store, settings=settings, now_seconds=now_seconds, account_id=function_account(_context))
1573
1516
  target = snapshot_target_from_environment()
1574
1517
  if settings.moderation_mode == "ai" and target is not None and _context is not None: