Skip to content

Commit c149993

Browse files
committed
feat(cedar-hitl): TaskApprovalsTable + SlackUserMapping + status enum
Lands the stateless CDK primitives for Cedar-HITL approval gates so Chunk 5's REST handlers can be wired onto concrete tables. Completes §15.2 tasks 9, 20, and 25. Constructs ---------- ``TaskApprovalsTable`` (§10.1) - PK ``task_id`` + SK ``request_id`` (ULID). Matches the agent-side primitives landed in the prior commit. - GSI ``user_id-status-index`` with user_id PK + status SK and an ``INCLUDE`` projection limited to the fields GET /v1/pending renders. Three deny-sensitive attrs (``deny_reason``, ``scope``, ``tool_input_sha256``) deliberately omitted from the projection — the list endpoint only returns PENDING rows in practice, but excluding them kills the projection-leak concern outright and costs no bytes today. - Exports ``USER_STATUS_INDEX_NAME`` as a module constant + mirrors it on ``construct.userStatusIndexName`` so handlers referencing the GSI fail compile-time on a rename. - TTL attribute ``ttl`` (agent writes ``created_at + timeout_s + 120s``). - No DynamoDB streams per §11.2. TaskEventsTable carries the audit fan-out; streams here would duplicate. - Default RemovalPolicy.DESTROY to match the rest of the sample. Production deploys override to RETAIN per §10.1. ``SlackUserMappingTable`` (§11.2, finding aws-samples#4) - Single-key (``slack_user_id`` PK). No SK, no TTL, no GSI, no stream. The forward-only shape is the trust boundary — a reverse GSI (Cognito → Slack) would let a compromised Cognito sub enumerate Slack identities without adding v1 capability. - Writes land through LinkSlackUserFn (Chunk 5) which enforces the ``attribute_not_exists(slack_user_id)`` condition so a prior legitimate mapping cannot be overwritten by a later compromise. ``task-status.ts`` — AWAITING_APPROVAL (§10.3) - Added to TaskStatus enum + ACTIVE_STATUSES (NOT TERMINAL_STATUSES: the task is alive, paused on a human decision). - VALID_TRANSITIONS wires the five edges §10.3 enumerates: RUNNING → AWAITING_APPROVAL (soft-deny entry) HYDRATING → AWAITING_APPROVAL (rare early-gate case) AWAITING_APPROVAL → RUNNING (approve / deny resume) AWAITING_APPROVAL → CANCELLED (user cancel mid-approval) AWAITING_APPROVAL → FAILED (stranded-approval reconciler) - Notably NOT added: AWAITING_APPROVAL → FINALIZING (approve-during-cleanup race) AWAITING_APPROVAL → COMPLETED (skip RUNNING) AWAITING_APPROVAL → TIMED_OUT (timer lives on the approval row, not the task clock) These are regression tests so a future refactor cannot quietly add them and bypass the `awaiting_approval_request_id = :rid` invariant. Tests: +29 total. - TaskApprovalsTable (11 tests): PK/SK schema, PAY_PER_REQUEST, PITR default + override, TTL attribute, NO streams, GSI schema + projection + sensitive-attr exclusion, removal policy default + override, ``USER_STATUS_INDEX_NAME`` constant parity with the construct field. - SlackUserMappingTable (8 tests): single-key schema (explicit KeySchema length assertion), PAY_PER_REQUEST, PITR, no streams, no reverse GSI, DESTROY default, TTL absent. - TaskStatus (+10 tests over existing: 5 new assertions on the 9-state cardinality, AWAITING_APPROVAL membership, and the transition graph including the three forbidden edges). The existing assertions updated for the new state count. No stack wiring yet — ``agent.ts`` instantiation + env var plumbing + grants land in the next commit alongside the Cedar-WASM Lambda layer.
1 parent 7cb78b8 commit c149993

6 files changed

Lines changed: 611 additions & 10 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* MIT No Attribution
3+
*
4+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy of
7+
* the Software without restriction, including without limitation the rights to
8+
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9+
* the Software, and to permit persons to whom the Software is furnished to do so.
10+
*
11+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
12+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
13+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
14+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
15+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
16+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
17+
* SOFTWARE.
18+
*/
19+
20+
import { RemovalPolicy } from 'aws-cdk-lib';
21+
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
22+
import { Construct } from 'constructs';
23+
24+
/**
25+
* Properties for SlackUserMappingTable construct.
26+
*/
27+
export interface SlackUserMappingTableProps {
28+
/**
29+
* Optional table name override.
30+
* @default - auto-generated by CloudFormation
31+
*/
32+
readonly tableName?: string;
33+
34+
/**
35+
* Removal policy for the table.
36+
* @default RemovalPolicy.DESTROY
37+
*/
38+
readonly removalPolicy?: RemovalPolicy;
39+
40+
/**
41+
* Whether to enable point-in-time recovery.
42+
* @default true
43+
*/
44+
readonly pointInTimeRecovery?: boolean;
45+
}
46+
47+
/**
48+
* DynamoDB table mapping Slack user IDs to Cognito subs for
49+
* Slack-button approvals (design §11.2, finding #4).
50+
*
51+
* Schema: `slack_user_id` (PK). One row per Slack identity; the sole
52+
* non-key attribute is `cognito_sub`. No GSI is provided for the
53+
* reverse direction (Cognito → Slack) because that lookup is not
54+
* trust-sensitive and can be derived offline if ever needed.
55+
*
56+
* Writes are gated through the `LinkSlackUserFn` Lambda (Chunk 5)
57+
* which requires BOTH a valid Cognito ID token AND a Slack OAuth
58+
* token for the user being mapped. Admins do not have a bulk-write
59+
* API, and the `ConditionExpression: attribute_not_exists(slack_user_id)`
60+
* on that Put prevents a subsequent Slack-admin compromise from
61+
* overwriting an existing mapping.
62+
*
63+
* NOT provided as part of this construct:
64+
*
65+
* - Stream (no consumer needs row-change events; every write is a
66+
* user-initiated link, audit lives on CloudTrail + the handler's
67+
* explicit audit event on TaskEventsTable).
68+
* - Reverse GSI (Cognito → Slack) — would widen the trust surface
69+
* without adding capability we need in v1.
70+
*/
71+
export class SlackUserMappingTable extends Construct {
72+
/**
73+
* The underlying DynamoDB table. Use this to grant access or read
74+
* the table name.
75+
*/
76+
public readonly table: dynamodb.Table;
77+
78+
constructor(scope: Construct, id: string, props: SlackUserMappingTableProps = {}) {
79+
super(scope, id);
80+
81+
this.table = new dynamodb.Table(this, 'Table', {
82+
tableName: props.tableName,
83+
partitionKey: {
84+
name: 'slack_user_id',
85+
type: dynamodb.AttributeType.STRING,
86+
},
87+
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
88+
pointInTimeRecoverySpecification: {
89+
pointInTimeRecoveryEnabled: props.pointInTimeRecovery ?? true,
90+
},
91+
removalPolicy: props.removalPolicy ?? RemovalPolicy.DESTROY,
92+
});
93+
}
94+
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/**
2+
* MIT No Attribution
3+
*
4+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy of
7+
* the Software without restriction, including without limitation the rights to
8+
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9+
* the Software, and to permit persons to whom the Software is furnished to do so.
10+
*
11+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
12+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
13+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
14+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
15+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
16+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
17+
* SOFTWARE.
18+
*/
19+
20+
import { RemovalPolicy } from 'aws-cdk-lib';
21+
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
22+
import { Construct } from 'constructs';
23+
24+
/**
25+
* GSI name for the `user_id-status-index`. Exported so handlers that
26+
* issue Query calls against the GSI reference the same identifier the
27+
* table was built with — renaming the GSI would break `bgagent pending`
28+
* without a loud failure.
29+
*/
30+
export const USER_STATUS_INDEX_NAME = 'user_id-status-index';
31+
32+
/**
33+
* Properties for TaskApprovalsTable construct.
34+
*/
35+
export interface TaskApprovalsTableProps {
36+
/**
37+
* Optional table name override.
38+
* @default - auto-generated by CloudFormation
39+
*/
40+
readonly tableName?: string;
41+
42+
/**
43+
* Removal policy for the table. The design (§10.1) recommends RETAIN
44+
* in production to preserve the audit trail; we default to DESTROY to
45+
* match the rest of the sample and keep teardowns cheap. Override per
46+
* environment.
47+
* @default RemovalPolicy.DESTROY
48+
*/
49+
readonly removalPolicy?: RemovalPolicy;
50+
51+
/**
52+
* Whether to enable point-in-time recovery.
53+
* @default true
54+
*/
55+
readonly pointInTimeRecovery?: boolean;
56+
}
57+
58+
/**
59+
* DynamoDB table for Cedar-HITL approval gates (design §10.1).
60+
*
61+
* Schema: `task_id` (PK, ULID matching TaskTable) + `request_id` (SK,
62+
* ULID minted by the agent). Each row represents one human-in-the-loop
63+
* approval gate; the agent writes PENDING, the ApproveTaskFn /
64+
* DenyTaskFn Lambdas (Chunk 5) update to APPROVED / DENIED, and the
65+
* reconciler sweeps STRANDED rows.
66+
*
67+
* A GSI (`user_id-status-index`) supports the `bgagent pending` access
68+
* pattern — `user_id = :caller AND status = :pending` — without
69+
* requiring a full-table Scan. The GSI ships in v1 because under
70+
* `watch -n1`-style polling the Scan alternative would exhaust DDB
71+
* burst capacity per-user (§10.1 finding #8).
72+
*
73+
* Streams are intentionally OFF (§11.2). TaskApprovalsTable is working
74+
* state; the audit trail lives on TaskEventsTable which already has
75+
* streams wired into the fan-out Lambda. Enabling streams here would
76+
* create duplicate fan-out paths.
77+
*
78+
* TTL is sized by the agent as `created_at_epoch + timeout_s + 120s`
79+
* so rows never expire during the decision window (§10.1).
80+
*/
81+
export class TaskApprovalsTable extends Construct {
82+
/**
83+
* The underlying DynamoDB table. Use this to grant access or read the
84+
* table name.
85+
*/
86+
public readonly table: dynamodb.Table;
87+
88+
/**
89+
* Name of the `user_id-status-index` GSI — callers that Query the GSI
90+
* should read this rather than hard-coding the string.
91+
*/
92+
public readonly userStatusIndexName: string = USER_STATUS_INDEX_NAME;
93+
94+
constructor(scope: Construct, id: string, props: TaskApprovalsTableProps = {}) {
95+
super(scope, id);
96+
97+
this.table = new dynamodb.Table(this, 'Table', {
98+
tableName: props.tableName,
99+
partitionKey: {
100+
name: 'task_id',
101+
type: dynamodb.AttributeType.STRING,
102+
},
103+
sortKey: {
104+
name: 'request_id',
105+
type: dynamodb.AttributeType.STRING,
106+
},
107+
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
108+
timeToLiveAttribute: 'ttl',
109+
pointInTimeRecoverySpecification: {
110+
pointInTimeRecoveryEnabled: props.pointInTimeRecovery ?? true,
111+
},
112+
// NO DynamoDB stream — §11.2. TaskEventsTable is the audit fan-out
113+
// path; this table holds working state only.
114+
removalPolicy: props.removalPolicy ?? RemovalPolicy.DESTROY,
115+
});
116+
117+
// GSI for GET /v1/pending — user_id PK + status SK (§10.1).
118+
//
119+
// Projection is INCLUDE with exactly the non-key attributes the
120+
// pending-list endpoint needs: keeps per-write cost small while
121+
// keeping the list response small enough to render in the CLI
122+
// without additional GetItem round-trips.
123+
this.table.addGlobalSecondaryIndex({
124+
indexName: USER_STATUS_INDEX_NAME,
125+
partitionKey: {
126+
name: 'user_id',
127+
type: dynamodb.AttributeType.STRING,
128+
},
129+
sortKey: {
130+
name: 'status',
131+
type: dynamodb.AttributeType.STRING,
132+
},
133+
projectionType: dynamodb.ProjectionType.INCLUDE,
134+
nonKeyAttributes: [
135+
'task_id',
136+
'request_id',
137+
'tool_name',
138+
'tool_input_preview',
139+
'severity',
140+
'reason',
141+
'created_at',
142+
'timeout_s',
143+
],
144+
});
145+
}
146+
}

cdk/src/constructs/task-status.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,18 @@
2323
* States progress through the lifecycle: SUBMITTED -> HYDRATING ->
2424
* RUNNING -> FINALIZING -> terminal (COMPLETED / FAILED / CANCELLED / TIMED_OUT).
2525
* See ORCHESTRATOR.md for the full state transition table.
26+
*
27+
* AWAITING_APPROVAL is the Cedar-HITL soft-deny gate surface: the
28+
* task is alive but paused on a human decision. See
29+
* `docs/design/CEDAR_HITL_GATES.md` §10.3 for the joint
30+
* `status` + `awaiting_approval_request_id` invariant that callers
31+
* must preserve when transitioning in or out of this state.
2632
*/
2733
export const TaskStatus = {
2834
SUBMITTED: 'SUBMITTED',
2935
HYDRATING: 'HYDRATING',
3036
RUNNING: 'RUNNING',
37+
AWAITING_APPROVAL: 'AWAITING_APPROVAL',
3138
FINALIZING: 'FINALIZING',
3239
COMPLETED: 'COMPLETED',
3340
FAILED: 'FAILED',
@@ -52,22 +59,45 @@ export const TERMINAL_STATUSES: readonly TaskStatusType[] = [
5259

5360
/**
5461
* Active (non-terminal) states that indicate a task is still in progress.
62+
* AWAITING_APPROVAL counts as active — the task is alive, just paused
63+
* waiting on a human decision.
5564
*/
5665
export const ACTIVE_STATUSES: readonly TaskStatusType[] = [
5766
TaskStatus.SUBMITTED,
5867
TaskStatus.HYDRATING,
5968
TaskStatus.RUNNING,
69+
TaskStatus.AWAITING_APPROVAL,
6070
TaskStatus.FINALIZING,
6171
];
6272

6373
/**
6474
* Valid state transitions. Maps each state to the set of states it can transition to.
65-
* Derived from the transition table in ORCHESTRATOR.md.
75+
* Derived from the transition table in ORCHESTRATOR.md + §10.3 of the
76+
* Cedar HITL gates design (AWAITING_APPROVAL entries).
6677
*/
6778
export const VALID_TRANSITIONS: Readonly<Record<TaskStatusType, readonly TaskStatusType[]>> = {
6879
[TaskStatus.SUBMITTED]: [TaskStatus.HYDRATING, TaskStatus.FAILED, TaskStatus.CANCELLED],
69-
[TaskStatus.HYDRATING]: [TaskStatus.RUNNING, TaskStatus.FAILED, TaskStatus.CANCELLED],
70-
[TaskStatus.RUNNING]: [TaskStatus.FINALIZING, TaskStatus.CANCELLED, TaskStatus.TIMED_OUT, TaskStatus.FAILED],
80+
[TaskStatus.HYDRATING]: [
81+
TaskStatus.RUNNING,
82+
TaskStatus.AWAITING_APPROVAL,
83+
TaskStatus.FAILED,
84+
TaskStatus.CANCELLED,
85+
],
86+
[TaskStatus.RUNNING]: [
87+
TaskStatus.AWAITING_APPROVAL,
88+
TaskStatus.FINALIZING,
89+
TaskStatus.CANCELLED,
90+
TaskStatus.TIMED_OUT,
91+
TaskStatus.FAILED,
92+
],
93+
// AWAITING_APPROVAL transitions back to RUNNING on approve or denial
94+
// resume; CANCELLED on user cancel mid-approval; FAILED only via the
95+
// stranded-approval reconciler.
96+
[TaskStatus.AWAITING_APPROVAL]: [
97+
TaskStatus.RUNNING,
98+
TaskStatus.CANCELLED,
99+
TaskStatus.FAILED,
100+
],
71101
[TaskStatus.FINALIZING]: [TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.TIMED_OUT],
72102
[TaskStatus.COMPLETED]: [],
73103
[TaskStatus.FAILED]: [],

0 commit comments

Comments
 (0)