1---
2name: Turn an issue into a tested pull request
3tagline: Hand off a small feature with acceptance criteria and review the evidence.
4summary: Give Codex a scoped issue, a prepared cloud environment, and explicit
5 checks. Get an implementation, tests, documentation, and a reviewable change
6 with evidence for each acceptance criterion.
7bestFor:
8 - Startup engineers implementing a scoped API issue.
9 - Reviewers who need implementation and validation evidence together.
10 - Teams with acceptance criteria and a working test environment.
11starterPrompt:
12 title: Implement a scoped issue
13 body: >-
14 Implement [issue ID and title] in [repository] at [base branch and commit],
15
16 using the prepared cloud environment I selected for this task.
17
18
19 Input packet: [paste or attach the issue, acceptance criteria, relevant
20
21 files, public success/error schemas, authorization and pagination contract,
22
23 test fixtures, and approved validation commands].
24
25 Repository instructions: [AGENTS.md and any relevant nested instructions].
26
27 Scope: [files or component boundaries].
28
29 Out of scope: [explicit exclusions].
30
31 Required checks: [focused tests, relevant regression tests, type check,
32
33 lint, and any required CI checks].
34
35 Output: [create a draft PR in this repository if the available integration
36
37 supports it and I authorize it here / return a diff and draft PR
38 description].
39
40
41 First read the instructions and relevant code. Confirm the starting commit
42
43 and run the existing baseline checks using the execution tools available
44
45 in this environment. Record any new test files as still to be created.
46
47 If the baseline fails, access is missing, or a
48
49 required check cannot run, report the exact blocker before implementing.
50
51
52 State a short plan, then implement the smallest change that meets the
53
54 acceptance criteria. Follow existing auth, error, pagination, and testing
55
56 patterns. Add tests that distinguish the required behavior from the old
57
58 behavior. For this pagination example, require tied timestamps across a
59
60 page boundary, bounded traversal and replay on fixed fixtures, partial and
61
62 exact-full final pages, authorization before cursor validation, invalid
63
64 cursor cases, and success/error schema checks. Update the affected API
65
66 documentation to match the implemented public response exactly.
67
68
69 After implementation, run the required checks again. For every acceptance
70
71 criterion, point to the test or other evidence that verifies it. Record
72
73 commands, results, the tested commit or working-tree state, and any checks
74
75 that were not run. Separate pre-existing failures from new failures.
76
77 Never describe an unrun check as passing.
78
79
80 Deliver the diff or authorized draft PR, a concise change summary, the
81
82 acceptance-criteria evidence table, remaining risks, and reviewer actions.
83
84 Do not merge, deploy, use production data, change permissions, add new
85
86 dependencies, or expand the agreed scope. Stop for a decision if the
87
88 contract conflicts with the repository or the work needs a schema change.
89relatedLinks:
90 - label: Download the runnable sample
91 url: /codex/use-cases/issue-to-tested-pr/sample.zip
92 - label: Cloud environments
93 url: https://learn.chatgpt.com/docs/environments/cloud-environments
94 - label: Keep documentation up-to-date
95 url: https://learn.chatgpt.com/use-cases/update-documentation
96---
97
98> For the complete documentation index, see [llms.txt](/llms.txt). Markdown versions of documentation pages are available by appending `.md` to the page URL.
99
100Hand Codex a small, well-defined issue and ask for a change a teammate can review: implementation, tests, documentation, and evidence that the requested behavior works. Keep the task in one prepared cloud environment so the code and validation use the same repository setup.
101
1021. In the web or desktop app, choose **Work in** > **Cloud** and select a published environment.
103
1042. To create one, select **Create environment**, choose the GitHub repositories, and ask Codex to prepare the runtime, dependencies, and isolated test services.
105
1063. Review the setup report, resolve failed checks, and **Publish** before starting a new task.
107
108- Follow the [current Cloud environment guide](https://learn.chatgpt.com/docs/environments/cloud-environments).
109- Keep repository guidance in [AGENTS.md](https://learn.chatgpt.com/docs/agent-configuration/agents-md).
110
111If dependencies change or service setup changes, edit the reusable environment, test it, and **Republish**, then verify the changes in a new task.
112
113Repository refresh does not rerun install/start commands, and existing tasks keep their own state.
114
115Configure required network destinations and test credentials separately; allowing a destination does not grant service access.
116
117If required checks cannot run, finish setup before using this workflow.
118
119## When to use it
120
121Choose a feature with a written contract and a clear stopping point: a new API endpoint, a validation rule, or a small addition to an existing service. The issue should fit in one pull request and have checks that can run without production access.
122
123The example adds pagination to a project's activity feed. The runnable Node.js sample includes authentication, project access, signed cursors, and an existing project-detail route. It uses fixed test records in memory, so you can check the pagination contract without a database or external service.
124
125## Try the runnable sample
126
127[Download the sample](https://developers.openai.com/codex/use-cases/issue-to-tested-pr/sample.zip), extract it, and open its `issue-to-tested-pr` folder. Use **Node 22.6 or later**. The sample uses the standard library and needs no package installation or API key.
128
129```bash
130node --version
131node api.mjs
132node test.mjs --seeded
133node test.mjs
134node reproduce.mjs ./evidence
135```
136
137`node api.mjs` starts a local HTTP server, requests the first page, prints its public IDs, and closes the server.
138
139The `--seeded` test run deliberately exits nonzero: its timestamp-only continuation skips two events tied at the first page boundary. The same tests pass with the corrected timestamp-and-ID tuple continuation.
140
141The reproduction command succeeds only if the seeded state fails and the corrected state passes.
142
143The corrected traversal with `limit=2` returns these pages in three requests:
144
145```text
146[evt_105, evt_104]
147[evt_103, evt_102]
148[evt_101]
149```
150
151The first two pages have a continuation cursor; the final page has `next_cursor: null`.
152
153The seeded state returns `[evt_105, evt_104]`, then `[evt_101]`, losing `evt_103` and `evt_102`.
154
155A successful run passes all 36 tests in the corrected state. The seeded state fails three assertions that expose the pagination defect.
156
157To verify your environment, put the files in your selected repository, prepare and publish an environment with a supported Node runtime, then run the commands in a new Cloud task and record its results.
158
159`reproduce.mjs` saves `red.log`, `green.log`, `observed-http.json`, and `run-manifest.json` in the requested evidence directory. The manifest identifies the runtime, commands, exit codes, seeded defect, correction, and SHA-256 source hashes.
160
161It does not record session tokens or the signing key. Read `README.md` and `openapi.json` for the sample's setup and public contract.
162
163### Inspect the runnable sample
164
165These excerpts come from the files in the download. Run the complete files after extracting the ZIP.
166
167<details>
168 <summary>Cursor and ordering implementation</summary>
169```javascript
170export function createApi({
171 sessions,
172 projects,
173 events,
174 signingKey,
175 seededBug = false,
176}) {
177 return createServer((request, response) => {
178 const send = (status, body) => {
179 response.writeHead(status, { "content-type": "application/json" });
180 response.end(JSON.stringify(body));
181 };
182 const fail = (code) => {
183 const [status, message] = errors[code];
184 send(status, { error: { code, message } });
185 };
186 // Authenticate, then authorize. Malformed parameters must not change these results.
187 const authorization = request.headers.authorization ?? "";
188 const bearer = /^Bearer ([A-Za-z0-9_-]+)$/.exec(authorization);
189 const tenantId = bearer ? sessions.get(bearer[1]) : undefined;
190 if (!tenantId) return fail("unauthenticated");
191
192 const url = new URL(request.url ?? "/", "http://127.0.0.1");
193 const route = /^\/v1\/projects\/([^/]+)(\/events)?$/.exec(url.pathname);
194 let projectId;
195 try {
196 projectId = route ? decodeURIComponent(route[1]) : undefined;
197 } catch {
198 return fail("not_found");
199 }
200 const project = projectId ? projects.get(projectId) : undefined;
201 if (
202 request.method !== "GET" ||
203 !project ||
204 project.tenant_id !== tenantId
205 ) {
206 return fail("not_found");
207 }
208 // Existing project-detail behavior remains independent of pagination parameters.
209 if (!route[2]) return send(200, { id: projectId, name: project.name });
210
211 const rawLimit = url.searchParams.get("limit");
212 const limit = rawLimit === null ? 25 : Number(rawLimit);
213 if (
214 url.searchParams.getAll("limit").length > 1 ||
215 (rawLimit !== null && !/^\d+$/.test(rawLimit)) ||
216 !Number.isInteger(limit) ||
217 limit < 1 ||
218 limit > 100
219 ) {
220 return fail("invalid_limit");
221 }
222 let cursor;
223 if (url.searchParams.has("cursor")) {
224 try {
225 if (url.searchParams.getAll("cursor").length !== 1)
226 throw new Error("Invalid cursor");
227 cursor = decodeCursor(
228 url.searchParams.get("cursor"),
229 tenantId,
230 projectId,
231 signingKey
232 );
233 } catch {
234 return fail("invalid_cursor");
235 }
236 }
237
238 const matching = events
239 .filter(
240 (event) =>
241 event.tenant_id === tenantId && event.project_id === projectId
242 )
243 .sort(compareEvents)
244 .filter((event) => {
245 if (!cursor) return true;
246 // Deliberate baseline defect: timestamp-only continuation loses tied rows.
247 if (seededBug)
248 return (
249 timestampValue(event.created_at) < timestampValue(cursor.created_at)
250 );
251 return compareEvents(event, cursor) > 0;
252 });
253 const selected = matching.slice(0, limit);
254 const items = selected.map(({ id, created_at, type, summary }) => ({
255 id,
256 created_at,
257 type,
258 summary,
259 }));
260 const nextCursor =
261 matching.length > limit
262 ? encodeCursor(selected.at(-1), tenantId, projectId, signingKey)
263 : null;
264 return send(200, { items, next_cursor: nextCursor });
265 });
266}
267
268export async function startApi(fixtures, options = {}) {
269 const server = createApi({ ...fixtures, ...options });
270 await new Promise((resolve, reject) => {
271 server.once("error", reject);
272 server.listen(0, "127.0.0.1", () => resolve(undefined));
273 });
274 const address = server.address();
275 if (!address || typeof address === "string")
276 throw new Error("No listening address");
277 return {
278 baseUrl: `http://127.0.0.1:${address.port}`,
279 close: () =>
280 new Promise((resolve, reject) =>
281 server.close((error) => (error ? reject(error) : resolve(undefined)))
282 ),
283 };
284}
285```
286
287</details>
288
289<details>
290 <summary>Seeded records and project access fixtures</summary>
291```javascript
292export function createFixtures() {
293 const tokenA = randomBytes(24).toString("base64url");
294 const tokenB = randomBytes(24).toString("base64url");
295 const sessions = new Map([
296 [tokenA, "tenant_a"],
297 [tokenB, "tenant_b"],
298 ]);
299 const projects = new Map([
300 ["feed", { tenant_id: "tenant_a", name: "Activity feed" }],
301 ["empty", { tenant_id: "tenant_a", name: "Empty feed" }],
302 ["exact", { tenant_id: "tenant_a", name: "Four events" }],
303 ["large", { tenant_id: "tenant_a", name: "Large feed" }],
304 ["precision", { tenant_id: "tenant_a", name: "Precise timestamps" }],
305 ["other", { tenant_id: "tenant_a", name: "Another project" }],
306 ["private", { tenant_id: "tenant_b", name: "Private project" }],
307 ]);
308 const event = (id, created_at, project_id, tenant_id = "tenant_a") => ({
309 id,
310 created_at,
311 project_id,
312 tenant_id,
313 type: "deployment",
314 summary: `Synthetic event ${id}`,
315 internal_note: "This field must never enter the HTTP response.",
316 raw_payload: { synthetic: true },
317 });
318 const feed = [
319 event("evt_105", "2026-01-15T12:00:03.123456Z", "feed"),
320 event("evt_104", "2026-01-15T12:00:02.123456Z", "feed"),
321 event("evt_103", "2026-01-15T12:00:02.123456Z", "feed"),
322 event("evt_102", "2026-01-15T12:00:02.123456Z", "feed"),
323 event("evt_101", "2026-01-15T12:00:01.123456Z", "feed"),
324 ];
325 const events = [
326 ...feed,
327 ...feed.slice(0, 4).map((item) => ({ ...item, project_id: "exact" })),
328 ...Array.from({ length: 105 }, (_, index) =>
329 event(
330 `large_${String(index).padStart(3, "0")}`,
331 new Date(Date.UTC(2026, 0, 15, 12, 0, index))
332 .toISOString()
333 .replace(".000Z", ".123456Z"),
334 "large"
335 )
336 ),
337 // Reverse ID order makes timestamp precision observable in the tests.
338 event("precise_a", "2026-01-15T12:00:00.123999Z", "precision"),
339 event("precise_z", "2026-01-15T12:00:00.123001Z", "precision"),
340 event("other_1", "2026-01-15T12:00:00.123456Z", "other"),
341 event("other_2", "2026-01-15T11:59:59.123456Z", "other"),
342 event("private_1", "2026-01-15T12:00:00.123456Z", "private", "tenant_b"),
343 event("private_2", "2026-01-15T11:59:59.123456Z", "private", "tenant_b"),
344 ];
345 return {
346 sessions,
347 projects,
348 events,
349 signingKey: randomBytes(32),
350 tokenA,
351 tokenB,
352 };
353}
354```
355
356</details>
357
358<details>
359 <summary>HTTP contract and regression tests</summary>
360```javascript
361test("five-event traversal crosses a timestamp tie and replays the same continuation", async () => {
362 const expected = ["evt_105", "evt_104", "evt_103", "evt_102", "evt_101"];
363 const pages = await traverse("feed", 2, expected);
364 assert.deepEqual(pages.map(ids), [
365 ["evt_105", "evt_104"],
366 ["evt_103", "evt_102"],
367 ["evt_101"],
368 ]);
369 assert.equal(pages.length, 3);
370 assert.ok(pages[0].next_cursor);
371 assert.ok(pages[1].next_cursor);
372 assert.equal(pages[2].next_cursor, null);
373 const replay = await request("feed", {
374 limit: "2",
375 cursor: pages[0].next_cursor,
376 });
377 assert.deepEqual(replay.body.items, pages[1].items);
378 const next = await request("feed", {
379 limit: "2",
380 cursor: replay.body.next_cursor,
381 });
382 assert.deepEqual(next.body.items, pages[2].items);
383 assert.equal(next.body.next_cursor, null);
384 console.log(
385 JSON.stringify({
386 fixture: "five-event-tie",
387 pages: pages.map(ids),
388 requests: pages.length,
389 replay: ids(replay.body),
390 })
391 );
392});
393
394test("the same tie group traverses correctly with limit three", async () => {
395 const pages = await traverse("feed", 3, [
396 "evt_105",
397 "evt_104",
398 "evt_103",
399 "evt_102",
400 "evt_101",
401 ]);
402 assert.deepEqual(pages.map(ids), [
403 ["evt_105", "evt_104", "evt_103"],
404 ["evt_102", "evt_101"],
405 ]);
406});
407
408test("timestamp ordering and continuation preserve submillisecond precision", async () => {
409 const pages = await traverse("precision", 1, ["precise_a", "precise_z"]);
410 assert.equal(pages[0].items[0].created_at, "2026-01-15T12:00:00.123999Z");
411 assert.equal(pages[1].items[0].created_at, "2026-01-15T12:00:00.123001Z");
412});
413
414test("omitted, minimum, and maximum limits return 25, 1, and 100 of 105 events", async () => {
415 for (const [parameters, expected] of [
416 [{}, 25],
417 [{ limit: "1" }, 1],
418 [{ limit: "100" }, 100],
419 ]) {
420 const result = await request("large", parameters);
421 assert.equal(result.status, 200);
422 assert.equal(result.body.items.length, expected);
423 assert.ok(result.body.next_cursor);
424 }
425 const pages = await traverse(
426 "large",
427 100,
428 Array.from(
429 { length: 105 },
430 (_, index) => `large_${String(104 - index).padStart(3, "0")}`
431 )
432 );
433 assert.deepEqual(
434 pages.map((page) => page.items.length),
435 [100, 5]
436 );
437});
438
439test("invalid and repeated limits return the documented generic error", async (context) => {
440 for (const limit of [
441 "0",
442 "101",
443 "-1",
444 "1.5",
445 "many",
446 "",
447 "1e2",
448 "Infinity",
449 ]) {
450 await context.test(`limit ${JSON.stringify(limit)}`, async () => {
451 const result = await request("feed", { limit });
452 assert.equal(result.status, 400);
453 assert.deepEqual(
454 result.body,
455 expectedError(
456 "invalid_limit",
457 "Limit must be an integer from 1 through 100."
458 )
459 );
460 });
461 }
462 const repeated = await request("feed", [
463 ["limit", "1"],
464 ["limit", "2"],
465 ]);
466 assert.equal(repeated.status, 400);
467});
468
469test("empty, partial, and exactly full final pages have no continuation", async () => {
470 assert.deepEqual((await request("empty")).body, {
471 items: [],
472 next_cursor: null,
473 });
474 const partial = await traverse("feed", 2, [
475 "evt_105",
476 "evt_104",
477 "evt_103",
478 "evt_102",
479 "evt_101",
480 ]);
481 assert.equal(partial.at(-1).items.length, 1);
482 const exact = await traverse("exact", 2, [
483 "evt_105",
484 "evt_104",
485 "evt_103",
486 "evt_102",
487 ]);
488 assert.deepEqual(
489 exact.map((page) => page.items.length),
490 [2, 2]
491 );
492 assert.equal(exact[1].next_cursor, null);
493});
494
495test("authentication precedes malformed and wrong-scope cursor or limit validation", async () => {
496 const privateCursor = (
497 await request("private", { limit: "1" }, fixtures.tokenB)
498 ).body.next_cursor;
499 for (const token of [null, "invalid_session"]) {
500 for (const parameters of [
501 { cursor: "broken" },
502 { cursor: privateCursor },
503 { limit: "0", cursor: "broken" },
504 ]) {
505 const result = await request("feed", parameters, token);
506 assert.equal(result.status, 401);
507 assert.deepEqual(result.body, unauthenticated);
508 }
509 }
510});
511
512test("inaccessible and nonexistent projects return identical 404 before cursor validation", async () => {
513 const ownCursor = (await request("feed", { limit: "1" })).body.next_cursor;
514 for (const project of ["private", "missing"]) {
515 for (const parameters of [
516 {},
517 { cursor: "broken" },
518 { cursor: ownCursor },
519 { limit: "0", cursor: "broken" },
520 ]) {
521 const result = await request(project, parameters);
522 assert.equal(result.status, 404);
523 assert.deepEqual(result.body, notFound);
524 }
525 }
526});
527
528test("a valid cursor from another project in the same tenant is invalid on an authorized feed", async () => {
529 const cursor = (await request("other", { limit: "1" })).body.next_cursor;
530 assert.ok(cursor);
531 const result = await request("feed", { cursor });
532 assert.equal(result.status, 400);
533 assert.deepEqual(result.body, invalidCursor);
534});
535
536test("a valid cursor from another tenant is invalid on an authorized feed", async () => {
537 const cursor = (await request("private", { limit: "1" }, fixtures.tokenB))
538 .body.next_cursor;
539 assert.ok(cursor);
540 const result = await request("feed", { cursor });
541 assert.equal(result.status, 400);
542 assert.deepEqual(result.body, invalidCursor);
543});
544
545test("undecodable, empty, tampered, and repeated cursor tokens return the same closed error", async () => {
546 const cursor = (await request("feed", { limit: "1" })).body.next_cursor;
547 const [payload, signature] = cursor.split(".");
548 const tampered = `${payload}.${signature[0] === "A" ? "B" : "A"}${signature.slice(1)}`;
549 for (const token of [
550 "broken",
551 "",
552 "%%%.___",
553 "a.b.c",
554 "x".repeat(2049),
555 tampered,
556 ]) {
557 const result = await request("feed", { cursor: token });
558 assert.equal(result.status, 400);
559 assert.deepEqual(result.body, invalidCursor);
560 }
561 const result = await request("feed", [
562 ["cursor", cursor],
563 ["cursor", cursor],
564 ]);
565 assert.equal(result.status, 400);
566 assert.deepEqual(result.body, invalidCursor);
567});
568
569test("signed structurally invalid payloads and unsupported versions are rejected", async (context) => {
570 const valid = {
571 version: 1,
572 tenant_id: "tenant_a",
573 project_id: "feed",
574 created_at: "2026-01-15T12:00:02.123456Z",
575 id: "evt_104",
576 };
577 const missing = { ...valid };
578 delete missing.id;
579 const cases = [
580 ["missing ID", missing],
581 ["number ID", { ...valid, id: 104 }],
582 ["empty ID", { ...valid, id: "" }],
583 ["number timestamp", { ...valid, created_at: 0 }],
584 ["invalid date", { ...valid, created_at: "2026-02-30T12:00:00Z" }],
585 ["unsupported version", { ...valid, version: 2 }],
586 ["extra field", { ...valid, extra: true }],
587 ["wrong tenant type", { ...valid, tenant_id: 1 }],
588 ["wrong project type", { ...valid, project_id: null }],
589 ["null payload", null],
590 ["array payload", []],
591 ["invalid JSON", "not-json"],
592 ];
593 for (const [name, payload] of cases) {
594 await context.test(name, async () => {
595 const result = await request("feed", { cursor: signFixture(payload) });
596 assert.equal(result.status, 400);
597 assert.deepEqual(result.body, invalidCursor);
598 });
599 }
600});
601
602test("every serialized event omits stored tenant, project, and raw payload fields", async () => {
603 const result = await request("feed", { limit: "100" });
604 assert.equal(result.status, 200);
605 for (const item of result.body.items) {
606 assert.deepEqual(Object.keys(item).sort(), [
607 "created_at",
608 "id",
609 "summary",
610 "type",
611 ]);
612 }
613 assert.ok(
614 fixtures.events.some((event) => event.internal_note && event.raw_payload)
615 );
616});
617
618test("the schema oracle rejects extra, missing, incorrectly typed, and malformed public fields", () => {
619 const schema = specification.components.schemas.EventPage;
620 const page = {
621 items: [
622 {
623 id: "evt_1",
624 created_at: "2026-01-15T12:00:00.123456Z",
625 type: "deployment",
626 summary: "Synthetic",
627 },
628 ],
629 next_cursor: null,
630 };
631 assert.ok(conforms(page, schema));
632 assert.equal(conforms({ ...page, tenant_id: "tenant_a" }, schema), false);
633 assert.equal(
634 conforms(
635 { ...page, items: [{ ...page.items[0], raw_payload: {} }] },
636 schema
637 ),
638 false
639 );
640 assert.equal(conforms({ items: page.items }, schema), false);
641 assert.equal(conforms({ ...page, next_cursor: "" }, schema), false);
642 assert.equal(
643 conforms({ ...page, items: [{ ...page.items[0], id: 1 }] }, schema),
644 false
645 );
646 assert.equal(
647 conforms(
648 { ...page, items: [{ ...page.items[0], created_at: "yesterday" }] },
649 schema
650 ),
651 false
652 );
653 const errorSchema = specification.components.schemas.InvalidCursor;
654 assert.equal(
655 conforms(
656 { error: { ...invalidCursor.error, stack: "internal" } },
657 errorSchema
658 ),
659 false
660 );
661 assert.equal(
662 conforms(
663 { error: { ...invalidCursor.error, message: "tenant_a" } },
664 errorSchema
665 ),
666 false
667 );
668});
669
670test("the existing project-detail route preserves access and ignores pagination parameters", async () => {
671 const own = await request(
672 "feed",
673 { cursor: "broken", limit: "0" },
674 fixtures.tokenA,
675 true
676 );
677 assert.equal(own.status, 200);
678 assert.deepEqual(own.body, { id: "feed", name: "Activity feed" });
679 const inaccessible = await request("private", {}, fixtures.tokenA, true);
680 assert.equal(inaccessible.status, 404);
681 assert.deepEqual(inaccessible.body, notFound);
682 const unauthenticatedResult = await request("feed", {}, null, true);
683 assert.equal(unauthenticatedResult.status, 401);
684 assert.deepEqual(unauthenticatedResult.body, unauthenticated);
685});
686
687test("OpenAPI declares operation, limit boundaries, signed cursor, and response statuses", () => {
688 assert.equal(specification.openapi, "3.1.0");
689 const operation = specification.paths["/v1/projects/{project_id}/events"].get;
690 assert.equal(operation.operationId, "listProjectEvents");
691 const limit = operation.parameters.find(
692 (parameter) => parameter.name === "limit"
693 ).schema;
694 assert.deepEqual(limit, {
695 type: "integer",
696 minimum: 1,
697 maximum: 100,
698 default: 25,
699 });
700 assert.deepEqual(Object.keys(operation.responses).sort(), [
701 "200",
702 "400",
703 "401",
704 "404",
705 ]);
706 assert.equal(
707 specification.components.securitySchemes.session.scheme,
708 "bearer"
709 );
710});
711```
712
713</details>
714
715<details>
716 <summary>Evidence capture</summary>
717```javascript
718mkdirSync(output, { recursive: true });
719const states = [];
720for (const [name, arguments_] of [
721 ["red", ["--seeded"]],
722 ["green", []],
723]) {
724 const result = spawnSync(
725 process.execPath,
726 [resolve(directory, "test.mjs"), ...arguments_],
727 {
728 cwd: directory,
729 encoding: "utf8",
730 timeout: 30_000,
731 }
732 );
733 writeFileSync(
734 resolve(output, `${name}.log`),
735 `${result.stdout ?? ""}${result.stderr ?? ""}`
736 );
737 states.push({
738 name,
739 command: `node test.mjs${arguments_.length ? " --seeded" : ""}`,
740 exit_code: result.status,
741 signal: result.signal,
742 error: result.error?.message ?? null,
743 });
744}
745const sources = Object.fromEntries(
746 readdirSync(directory, { withFileTypes: true })
747 .filter((entry) => entry.isFile())
748 .map((entry) => [
749 entry.name,
750 createHash("sha256")
751 .update(readFileSync(resolve(directory, entry.name)))
752 .digest("hex"),
753 ])
754);
755const observed = { red: await observe(true), green: await observe(false) };
756writeJson("observed-http.json", observed);
757writeJson("run-manifest.json", {
758 execution: "Synthetic loopback HTTP fixture; no external service",
759 recorded_at: new Date().toISOString(),
760 node: process.version,
761 minimum_node: "22.6.0",
762 dependencies: "Node standard library only; no install step",
763 baseline:
764 "Deliberately seeded timestamp-only continuation; not a historical customer defect",
765 correction:
766 "Descending timestamp-and-ID tuple continuation using full fractional precision",
767 source_sha256: sources,
768 states,
769});
770if (
771 states[0].exit_code === null ||
772 states[0].exit_code === 0 ||
773 states[1].exit_code !== 0
774) {
775 throw new Error(
776 "Expected the unchanged tests to fail on the seeded baseline and pass on the corrected implementation. Inspect red.log and green.log."
777 );
778}
779console.log(
780 JSON.stringify(
781 {
782 execution: "synthetic-loopback-http",
783 red_exit: states[0].exit_code,
784 green_exit: states[1].exit_code,
785 green_pages: observed.green.pages.map((page) =>
786 page.body.items.map((item) => item.id)
787 ),
788 evidence_directory: output,
789 },
790 null,
791 2
792 )
793);
794```
795
796</details>
797
798## Prepare the input packet
799
800An issue link is useful only if Codex can read it. Paste or attach the relevant content when the source is unavailable. Include the following information with the starter prompt.
801
802| Input | Sample issue |
803| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
804| Issue and outcome | `API-142`: Add `GET /v1/projects/{project_id}/events` so customers can page through an activity feed. |
805| Repository and base | Put the sample folder in your chosen repository, then record the branch and exact commit selected for the task. |
806| Relevant code | The sample's `api.mjs`, `fixtures.mjs`, `test.mjs`, and `openapi.json`, plus your repository's `AGENTS.md`. |
807| Existing patterns | Keep authentication and project authorization before pagination validation. Use signed cursors and the documented public error envelope. When adapting the issue, follow your repository's existing helpers. |
808| Test setup | Fixed in-memory test projects for two tenants, six tenant-A projects, a timestamp tie split across a page boundary, timestamps with fractional precision, empty and exactly full final pages, and 105 events for limit checks. |
809| Validation | `node test.mjs --seeded` (expected failure), `node test.mjs` (corrected state), and `node reproduce.mjs ./evidence`. Run `node --check` for each `.mjs` file. For your own service, add its actual type, lint, regression, and CI commands. |
810| Scope | Endpoint, focused tests, and its OpenAPI documentation. No new dependencies, database migration, frontend, deployment, or auth redesign. |
811| Handoff | A draft PR when the available repository integration supports it and you have authorized creation; otherwise a diff and draft PR description. |
812
813For the sample, prepare Node 22.6 or later; the tests create their own test sessions and cursor-signing key and bind a temporary HTTP server to the loopback interface. For your own service, also prepare its dependencies, test configuration, and isolated services. Verify that its cursor helper can bind a cursor to the authenticated tenant and requested project before assigning this issue.
814
815### Define the API contract
816
817Confirm the sample contract below matches your repository before implementation. A successful response is HTTP 200 with exactly `{ items, next_cursor }`.
818
819Each item has exactly four required public fields: `id` (nonempty string), `created_at` (UTC RFC 3339 timestamp string), `type` (nonempty string), and `summary` (string). `items` is an array of these objects; `next_cursor` is an opaque nonempty string or `null`.
820
821Internal tenant/project keys, database metadata, raw event payloads, and credentials are not response fields. Describe response field types, required fields, whether fields allow `null`, and closed object shapes in OpenAPI. Verify the serialized HTTP bodies against that schema.
822
823Events sort by `created_at DESC, id DESC`, preserving the timestamp's fractional precision and using descending lexical ID order. The cursor resumes strictly after the last returned tuple in that order.
824
825The default page size is 25; valid limits are decimal integers from 1 through 100. Repeated limit or cursor parameters are invalid. Return a non-null cursor only when another matching event exists.
826
827Both a partial final page and an exactly full final page return `next_cursor: null`; an empty authorized project returns `items: []` and `next_cursor: null`.
828
829The sample signs each cursor and binds it to the authenticated tenant and requested project. Authenticate and authorize the requested project before validating its limit or cursor, so parameter errors cannot reveal whether another tenant's project exists.
830
831A missing or invalid session returns 401; an inaccessible or nonexistent project returns the same generic 404. After confirming access, check the limit before the cursor.
832
833A malformed, tampered, structurally invalid, or tenant/project-mismatched cursor returns HTTP 400 with code `invalid_cursor`. A valid cursor for another project in the same tenant, or for another tenant, is invalid when supplied to an authorized project.
834
835A supplied cursor must not change the authorization result for an inaccessible project. The sample's version-1 cursor does not expire; unsupported versions are invalid.
836
837In the sample, every error body has exactly `{ error: { code, message } }`, with both fields required strings and no extra fields.
838
839Use these generic error responses:
840
841- 400 `invalid_cursor` / `Invalid cursor.`
842- 400 `invalid_limit` / `Limit must be an integer from 1 through 100.`
843- 401 `unauthenticated` / `Authentication required.`
844- 404 `not_found` / `Project not found.`
845
846Keep decoded cursor contents, tenant/project identifiers, raw errors, and stack traces out of every error body. Use the real repository's approved envelope and codes if they differ; resolve that contract difference before changing shared helpers.
847
848Traversal and replay assertions use fixed fixtures with no concurrent writes: following cursors returns every event once in the agreed order and terminates within `ceil(event_count / limit)` requests for a nonempty feed, or one request for an empty feed.
849
850Replaying a valid cursor with the same request parameters returns the same items and continuation position while those fixtures stay unchanged; it need not produce byte-identical signed tokens.
851
852This contract does not promise single-use cursors, snapshot pagination, or identical results after feed changes. Document the repository's existing feed consistency policy; stop for a product decision if no policy exists and the issue requires snapshot behavior.
853
854## How to use
855
8561. **Choose the issue and prepare the environment.** Confirm that the contract above matches the actual repository. Select the prepared cloud environment and the intended base commit. Supply the issue packet directly in the task; starting this recipe does not depend on an automatic issue-tracker trigger.
8572. **Run the starter prompt.** Replace its bracketed inputs with your repository, issue packet, boundaries, and checks. Choose whether Codex should return a diff or create an authorized draft PR. The starter prompt on this page contains the full handoff instructions.
8583. **Establish the baseline.** Codex should read repository instructions and run the approved baseline checks before editing.
859
860 For the sample, run `node test.mjs` and the syntax checks in `README.md`; `--seeded` is a separate deliberate failure demonstration.
861
862 For your own service, run its existing regression, type, and lint checks. A new endpoint's focused test file may not exist yet; record that as a test to create. Resolve failing existing checks or unavailable test services before attributing later results to the feature.
863
8644. **Implement and verify the contract.** Ask Codex to use the existing access and cursor helpers, implement the endpoint, add the acceptance tests below, and update its API documentation. Where practical, the new tests should fail against the original code and pass after the change. Keep those results with the final verification record. In the downloadable sample, `--seeded` selects a deliberate defect for this comparison; this comparison is separate from a failing baseline in your repository.
8655. **Review the change and its evidence.** Read the diff, acceptance checks, check outputs, and remaining risks together. If Codex creates an authorized draft PR, verify that its base and head are correct. If PR tooling is unavailable, use the returned diff and description to prepare the PR through your normal process. List any unresolved checks in the draft PR and keep it unready for merge.
8666. **Resolve review feedback and merge through your normal process.** Give Codex the specific comments to address in the same task, then rerun the affected checks. A reviewer confirms required CI and approves the final commit before merging.
867
868## Acceptance tests to require
869
870These tests make the issue small enough to delegate and specific enough to review. Adapt the names to your test runner, while preserving the behavior each test proves.
871
872### Stable traversal
873
874Seed five fixed events: `evt_105` has the newest timestamp, `evt_104`, `evt_103`, and `evt_102` share the middle timestamp, and `evt_101` has the oldest.
875
876With `limit=2`, require exactly these pages: `[evt_105, evt_104]`, `[evt_103, evt_102]`, `[evt_101]`. The tie group must cross the first boundary.
877
878Assert exact order, no duplicate or missing IDs, non-null cursors on the first two pages, and a null cursor on the third; fail if traversal exceeds three requests.
879
880Replay the first continuation cursor with the same limit against unchanged fixtures and assert the same second-page items and continuation position.
881
882### Default and boundary sizes
883
884Seed more than 100 events. Assert 25 items without a `limit` parameter, one with `limit=1`, and 100 with `limit=100`.
885
886### Invalid limits
887
888Assert HTTP 400 for `0`, `101`, `-1`, a fractional value, and a string such as `many`, using the existing validation error shape.
889
890### Empty and final pages
891
892Assert an empty authorized project returns `items: []` and `next_cursor: null`. With `limit=2`, assert that five events produce a one-item third page with a null cursor and that four events produce a two-item second page with a null cursor. The full final page must not require an extra empty request.
893
894### Authentication
895
896Assert missing and invalid sessions return the agreed 401 schema, including requests with a malformed cursor or a cursor from another scope. Cursor validation must not override authentication.
897
898### Tenant isolation
899
900Authenticate as tenant A and request tenant B's project or a nonexistent project. Assert the same agreed generic 404 body with no event data, including when the request carries a malformed or mismatched cursor. Authorization must precede cursor validation.
901
902### Cursor scope
903
904Request an authorized tenant-A project with (1) a valid signed cursor from another tenant-A project and (2) a valid signed cursor issued under tenant B.
905
906Assert both return the same HTTP 400 `invalid_cursor` body with no event data or scope details. Test these separately from requests to an inaccessible project, which must return 404.
907
908### Malformed and tampered cursors
909
910On an authorized project, assert HTTP 400 `invalid_cursor` for tokens the helper cannot decode, tokens with modified signatures, and signed tokens that omit required cursor fields or give those fields the wrong type. Use isolated test signing fixtures.
911
912Cover unsupported versions or expired tokens if the shared helper supports those features. Assert that responses match the agreed generic error schema and contain no decoded contents, identifiers, raw errors, or stack traces.
913
914### Existing behavior
915
916The sample checks the existing project-detail route with authorized, unauthorized, and inaccessible-project requests. Run syntax checks for its `.mjs` files. In your service, run its established regression suite, type check, and lint.
917
918### Public response schema
919
920Validate actual HTTP 200, 400, 401, and 404 response bodies against the agreed OpenAPI schemas. Assert required keys, item field types, timestamp format, whether the cursor allows `null`, and no extra properties at any object level.
921
922Seed internal fields in stored events and verify they are absent from serialized items; verify every error response contains only its approved generic code and message.
923
924### Documentation
925
926Verify the OpenAPI operation and examples match the implemented parameters, exact success/error schemas, authorization precedence, ordering, final-page rules, and fixed-fixture replay/consistency limits. Run the repository's API specification validation if one exists.
927
928## What good output looks like
929
930### Shared evidence record
931
932Use this evidence format for each acceptance criterion. Populate it from observed output. For a new test that does not exist at baseline, distinguish “not yet present” from a failed existing check.
933
934```text
935Criterion / test: [specific behavior and test location]
936Environment: [published setup reference; runtime, database and dependency versions]
937Before: [revision + test-only diff ID; command; exit code; observed assertion; log path]
938After: [final revision or base + diff ID; command; exit code; observed assertion; log path]
939Status: [pass / fail / blocked / not run]
940Limits / reviewer action: [untested behavior, blocker, or decision required]
941```
942
943Keep red and green runs tied to their actual tested states, including test-only changes on the original revision. Keep the logs, response fixtures, and final diff with the handoff; file and test names alone do not prove execution. Update the evidence when the tested revision changes. Checks that are blocked or have not run cannot count as passing.
944
945A useful PR description links each claim to its evidence. Use this handoff template and replace every bracketed field with observed results.
946
947```text
948Title: Add cursor pagination for project events
949Issue: API-142
950Base: [branch and commit]
951Tested state: [head commit, or base commit plus attached diff identifier]
952
953Changes
954- Add the project-events endpoint using the existing project access helper.
955- Reuse signed cursors scoped to tenant and project.
956- Add contract tests and update the OpenAPI operation.
957
958Acceptance evidence
959- [One row per criterion above: test, observed result, and log reference.]
960
961Validation
962- Before edits: [command, result, and log reference for each baseline check].
963- New tests against original code: [result, or not run with reason].
964- After edits: [command, result, and log reference for each required check].
965- CI: [check links and actual states, or not available].
966- Remaining checks: [specific checks and blockers, or none with supporting evidence].
967
968Reviewer focus
969- [Highest-risk behavior and unresolved criteria.]
970
971Remaining risk and rollback
972- [Observed limitations; distinguish them from untested assumptions.]
973- [How this change can be reverted and whether any callers depend on it.]
974```
975
976## Iterate from review comments
977
978Use a focused follow-up for a specific review finding. For example:
979
980```text
981Address this review comment in the current change:
982"Repeat the five-event traversal with limit=3; assert pages
983[evt_105, evt_104, evt_103] and [evt_102, evt_101], then a null cursor."
984
985Add the regression test and change the implementation only if it exposes a
986failure. Rerun the focused endpoint tests and any other required checks affected
987by the edit. Update the evidence table with the tested state and actual results.
988Keep the same scope. Do not merge or deploy.
989```
990
991Provide the text of the comment if Codex cannot access the review directly. A reviewer should check that the response resolves the behavior in question, rather than only satisfying the wording of the comment.
992
993## When to stop
994
995- **The baseline is red or cannot run.** Return the failing check and setup details. Fix the environment or agree on a documented baseline before continuing.
996- **The contract conflicts with existing behavior.** Identify the conflict, affected callers, and the smallest decision needed. Do not silently change the contract.
997- **The change needs a migration, new dependency, or auth redesign.** Explain why and split or re-scope the issue before expanding it.
998- **Tests fail after implementation.** Do not treat the change as ready to merge. Report the failing cases. Do not remove assertions or weaken authorization to make checks pass.
999- **Repository write integration is unavailable.** Return the diff, draft PR description, and validation evidence. A teammate can complete the repository handoff.
1000
1001## Reuse the workflow
1002
1003After a reviewer accepts the first result, save the issue packet and acceptance checklist in your team's issue template. Record stable repository guidance, such as test commands and where API contracts live, in `AGENTS.md` or the existing contributor guide.