Skip to content

The DoorDash skin: rendi's first non-analytics capability - #1

Merged
mcheemaa merged 10 commits into
mainfrom
feat/doordash-ordering
Aug 11, 2026
Merged

mcheemaa merged 10 commits into
mainfrom
feat/doordash-ordering

Conversation

@mcheemaa

@mcheemaa mcheemaa commented Aug 11, 2026 •

Copy link
Copy Markdown
Owner

rendi is an agent harness wearing analytics as its first skin. This PR grows the second skin: ordering food through the official DoorDash CLI, built slice by slice on one long-running branch.

The arc:

  • S0: conversations carry their birth environment. Prod lists prod-born only; cross-environment opens render as read-only galleries in both directions (generalizing the archive mechanism); archived pages no longer attach sessions they cannot resume. Precondition for safe local development against the shared database.
  • S1: dd-cli wrapper with typed envelopes grounded in live captures, the read-only browse tool (ten verbs), the browse card with store tiles and an item spotlight, zoomable food images. Popularity fields stripped per the CLI's own embargo; location resolves from the saved default address per their doctrine.
  • S2: the cart is an instrument. Three tables (carts, approvals, orders), the cart tool with the one-cart-per-store preflight, a non-model exec task for touch edits, and the cart card: steppers, tip presets (10 percent at birth), delivery/pickup, the honest money ladder in their own cents with strikethroughs, closed stores say so.
  • S3: the approval machine: one-time codes by email, entered in the card, never through the model; the submit tool that only fires into a verified, single-use, hash-bound approval, consumes it exactly once, revalidates the live cart against the frozen hash, and voids on drift. Caps below the agent (order ceiling, daily budget, request rates), five wrong guesses kill a code, approval and order cards render every face reload-honestly, fifteen pglite tests pin the lifecycle. Proven live: two honest denials, a real code email, a counted wrong guess, a cancel that reopened the cart, zero dollars moved.
  • S4: production: the build extension bakes dd-cli v0.2.2 into the worker image, checksum-verified against the published release at build time; headless token auth proven from the deployed container (version 20260811.1, real search from the cloud); deploy guides, agent deploy guide, and env examples teach the whole ritual.

Ordering is personal-account only per the CLI's terms; the capability ships as configuration (own token, own approval inbox, spend caps) so any deployment can enable it or leave the tool line out of the agent definition.

Gates green per slice: lint, typecheck, tests, build.

A durable session lives forever in the Trigger environment it was born
in; the row now records which. Prod lists prod-born conversations only,
cross-environment opens render as read-only galleries in both
directions, and archived pages no longer attach a session they could
never resume.
@vercel

vercel Bot commented Aug 11, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
rendi Ready Ready Preview Aug 11, 2026 4:58pm

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 11cc6762b0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/rendi/trigger-env.ts
Comment on lines +15 to +16
const key = process.env.TRIGGER_SECRET_KEY;
if (!key) throw new Error("TRIGGER_SECRET_KEY is not set");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Seed the Trigger environment in persistence tests

When pnpm test runs in the checked-in CI environment, TRIGGER_SECRET_KEY is unset because the setup action only installs dependencies. Every pre-existing test in lib/rendi/harness/persistence.test.ts now reaches this throw through persistChatStart or reconcile, while the new test file's per-test environment changes are isolated and cannot initialize the existing suite, so the unit-test gate fails before its database assertions run. Set and restore a representative key in the persistence suite or inject the environment dependency.

AGENTS.md reference: AGENTS.md:L100-L107

Useful? React with 👍 / 👎.

@@ -0,0 +1 @@
ALTER TABLE "conversations" ADD COLUMN "trigger_env" text DEFAULT 'prod' NOT NULL; No newline at end of file

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve dev provenance when backfilling existing rows

On an existing production database, this migration labels every historical conversation as prod. The implementation being replaced explicitly classified conversations created before 2026-07-22T20:40:00Z as dev-born galleries, so those rows now pass isArchived in production and page.tsx attaches their dev-only session tokens, producing the 401 this change is meant to prevent. Backfill pre-cutover rows as dev before applying the prod default to future inserts.

Useful? React with 👍 / 👎.

A read-only first skin-slice: the dd-cli wrapper with schemas grounded
in live captures (popularity fields stripped per the CLI's own embargo,
unknown keys dropped so context stays lean), the doordash-browse tool
with ten verbs, and one card with three faces: store tiles, an item
spotlight, and quiet summaries. Search resolves location from the saved
default address, their own doctrine. Money cannot move from here.
Both P1s were real; the first was already red in CI while passing
locally on ambient environment. Unit tests now seed a representative
dev key, and the pre-cutover provenance moves from a one-off into
migration 0009 so any replay reconstructs birth environments instead
of defaulting history to prod.
@mcheemaa

Copy link
Copy Markdown
Owner Author

Both P1s were real, and the first was already red in CI while passing locally on ambient environment. Fixed in d242e75: unit tests seed a representative dev key (vitest.setup.unit.ts), and the pre-cutover provenance now lives in migration 0009 so replaying the migrations reconstructs birth environments instead of defaulting history to prod.

Food is visual: thumbnails in the DoorDash cards grew a notch (stores
64px, spotlights 96px) and every one is now a zoom trigger opening the
CDN original at natural aspect in a dialog, escape to dismiss. Composed
from the installed dialog primitive; the spotlight story clicks it open
and closes it.
Three tables land (dd_carts render snapshots, dd_approvals for the
one-time codes, dd_orders keyed by approval so a re-fired turn can
never charge twice), and the wrapper grows the full cart lifecycle:
add-items with nested options, show, remove-item, delete, list,
preview, promo. Shapes come from live captures of a real cart walked
through every verb, which also proved the laws on the wire: append
sums quantities, carts build at closed stores, closed previews say so
honestly. Money is integer cents everywhere; dollars exist only at
render.
The agent builds carts through doordash-cart (with the one-cart-per-
store preflight as its opening move), and the user edits the same cart
by touch: steppers, remove, tip presets defaulting to 10 percent at
birth, delivery and pickup. Touches ride a non-model exec task, since
the binary lives in the worker, and both hands move the cart through
one shared physics: mutate at DoorDash, re-preview for honest money,
upsert the snapshot the card paints from. The ladder renders their
cents verbatim, strikethroughs included, and a closed store says so
instead of pretending. Nothing here can charge anything; the approval
machine is the next slice.
The agent holds the full tool surface, including submit; safety is
structural, not a handicap. doordash-request-approval re-prices the
cart, enforces the caps (order ceiling, daily budget, per-conversation
and per-day request rates), freezes everything into a sha256 hash, and
mails a six-digit code to the owner's inbox and nowhere else. The code
is typed into the order card, never into the chat: the verify route
checks it under a row lock with constant-time comparison, five wrong
guesses kill the approval, and success wakes the agent through the
session inbox. doordash-submit takes only the approval id: it consumes
the row once (a re-fired turn reads the recorded outcome instead of
charging twice), revalidates the live cart against the frozen hash,
voids on any drift, and polls the order to a terminal state it reports
honestly. DD_HIDE_PERSONAL=1 keeps addresses, cards, and history
private when strangers hold gate codes.

Approval and order cards render every face, reload-honest via a status
route, with stories under the axe gate; fifteen pglite tests pin the
machine's lifecycle. Proven live end to end tonight: two honest
denials (closed store, no ASAP delivery), a real code email, a wrong
guess counted, a cancel that reopened the cart, and not a cent moved.
The worker image gains the official dd-cli at deploy, pinned to v0.2.2
and checksum-verified against the published release; dev machines keep
their own login-authenticated install. Env examples and both deploy
guides teach the whole ritual: waitlisted account, dd-cli login locally,
export-token into the worker env, the owner inbox, the caps, and
DD_HIDE_PERSONAL for instances strangers can drive. Removing the four
tool lines from agent.md removes the capability, so a clone without
DoorDash access deploys clean.
@mcheemaa

Copy link
Copy Markdown
Owner Author

@codex review

Twenty guests on a challenge day would drain a hardcoded fifteen; the
inbox budget is now DD_MAX_APPROVALS_PER_DAY with the same default.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 21acccbab8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +76 to +80
const [orders] = await db
.select({ n: count() })
.from(ddOrders)
.where(gt(ddOrders.createdAt, dayAgo));
if ((orders?.n ?? 0) >= maxOrdersPerDay()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reserve daily-order capacity atomically

When multiple approvals are requested before any corresponding order is submitted, every request observes the same ddOrders count and passes this check. The approvals can then be verified and submitted sequentially or concurrently, and neither consumeApproval nor the order insert rechecks or reserves capacity, so even DD_MAX_ORDERS_PER_DAY=1 permits two real orders. Reserve a slot atomically when issuing or consuming an approval.

Useful? React with 👍 / 👎.

const liveHash = hashCart({
items,
totalCents: liveTotal ?? -1,
tipCents: approval.tipCents,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Invalidate approval when the saved tip changes

If the user changes the tip in a cart card after receiving the approval email, opSetTip updates dd_carts.tip_cents, but revalidation hashes approval.tipCents rather than the current snapshot tip. A tip-only edit therefore passes the hash check and submission charges the previously approved tip, even while the UI shows the user's newer amount. Compare the snapshot tip and void the approval when it differs.

AGENTS.md reference: AGENTS.md:L17-L18

Useful? React with 👍 / 👎.

await new Promise((resolve) => setTimeout(resolve, STATUS_POLL_MS));
}
}
await recordOutcome(orderRow.id, { status, errorMessage });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reconcile orders that outlive the initial poll

When DoorDash remains pending through all eight polls, this permanently records pending and returns, but the already-consumed retry path only rereads that row and no task or exposed tool polls the order again. The cart and order card consequently stay in “placing” forever even after DoorDash reaches a terminal state. Schedule durable reconciliation or make the consumed retry path refresh pending orders.

AGENTS.md reference: AGENTS.md:L15-L18

Useful? React with 👍 / 👎.

data: CartCardData;
exec?: typeof execOp;
}) {
const [cart, setCart] = useState<CartCardData>(data);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Hydrate cart cards from the live snapshot

On reload, and whenever another historical cart card edits the same UUID, this state is initialized from the frozen tool output in the transcript and never reads dd_carts. Each card can therefore display different quantities, fulfillment, tip, and totals, while a reload discards browser-side updates and restores historical numbers. Hydrate or subscribe by cart UUID so every rendered copy adopts the durable snapshot.

AGENTS.md reference: AGENTS.md:L15-L18

Useful? React with 👍 / 👎.

Comment on lines +53 to +55
if (run.status === "COMPLETED") {
const fresh = await getCartSnapshot(uuid);
return Response.json({ ok: true, cart: fresh ?? null });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Deliver browser cart edits to the next agent turn

After a browser edit completes, the fresh cart is returned only to that HTTP caller. The task writes no append-only operation or unseen marker, while trigger/chat.ts injects only instrument and canvas readback, so the next agent turn still sees the historical cart tool output and can discuss or approve a stale quantity, tip, or fulfillment choice. Persist cart edits and inject their live state on the next turn.

AGENTS.md reference: AGENTS.md:L17-L18

Useful? React with 👍 / 👎.

}
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
}
return Response.json({ error: "edit timed out" }, { status: 504 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stop timed-out cart tasks from mutating later

When this 60-second deadline expires, the route returns a definitive failure but leaves the Trigger task running for up to 120 seconds, including while it waits in the global single-concurrency queue. The task can therefore mutate DoorDash and persist the cart after the UI has reported that the edit failed, causing an unexpected quantity, tip, or fulfillment change. Cancel the run on timeout or return a durable pending state that the card continues to reconcile.

AGENTS.md reference: AGENTS.md:L17-L18

Useful? React with 👍 / 👎.

Comment thread trigger/tools/doordash-submit.ts Outdated
Comment on lines +121 to +129
await recordOutcome(orderRow.id, {
status: "failed",
errorMessage: error instanceof Error ? error.message : "submit failed",
});
await setCartStatus(approval.cartUuid, "failed");
return {
outcome: "failed",
error: error instanceof Error ? error.message : "submit failed",
note: "never resubmit; the browser checkout is the fallback",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve uncertainty when submission loses its response

A timeout or connection loss from orderSubmit does not prove DoorDash rejected the request, because the server may have accepted and charged it before the response was lost. This catch nevertheless records failed and recommends browser checkout, which can lead the user to place a duplicate order. Record an unknown/pending outcome and reconcile it through order history or status instead of presenting transport failure as rejection.

AGENTS.md reference: AGENTS.md:L76-L76

Useful? React with 👍 / 👎.

Comment on lines +98 to +101
default:
return {
title: "Placed the order",
summary: output.outcome ?? "",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Render not-found orders as unresolved

ddOrderStatus explicitly returns not_found, and the submit tool passes that value through as outcome, but it falls into this default branch and renders the title “Placed the order.” When DoorDash cannot find the submitted UUID, the user is therefore told the opposite of the actual result. Add an explicit unresolved or failed presentation for not_found.

AGENTS.md reference: AGENTS.md:L76-L76

Useful? React with 👍 / 👎.

Comment on lines +225 to +226
{output.message?.trim() ||
`${summarize(verb, output)} · in hand`}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Render hidden personal data as private

With DD_HIDE_PERSONAL=1, the browse tool returns { private: true, note: "the owner keeps that private" }, but this card ignores both fields. Personal verbs are consequently rendered as “0 orders,” “0 saved,” or “0 on file,” falsely presenting hidden information as an empty account. Surface the privacy outcome rather than passing the payload through the ordinary count fallback.

AGENTS.md reference: AGENTS.md:L76-L76

Useful? React with 👍 / 👎.

Comment on lines +77 to +87
await sendApprovalEmail({
code: approval.code,
storeName: priced.storeName,
itemsCount: priced.items.reduce((sum, line) => sum + line.quantity, 0),
totalDisplay: `$${(totalCents / 100).toFixed(2)}`,
destination:
priced.fulfillment === "pickup" ? "pickup" : "the saved address",
conversationTitle: conversation?.title ?? "a conversation",
minutes: 15,
approvalId: approval.id,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Void approvals whose email delivery fails

If sendApprovalEmail throws, the tool exits before returning the code card or setting awaiting_code, but the newly created approval remains live and is still counted by both approval-rate queries. Repeated transient Resend failures can therefore exhaust the per-conversation and global limits even though no authorization code was delivered. Void the row on delivery failure or count only approvals whose email was accepted.

Useful? React with 👍 / 👎.

First slice of the Codex findings, the three that matter before real
money moves. orderSubmit timeouts and transport deaths now surface as
DdUncertainError; the submit tool answers them by reading order
history (an order at this store our ledger has never seen is the one
whose response was lost), adopting it into the normal status poll, or
recording an honest unknown that tells everyone not to resubmit and
not to check out elsewhere. A tip edited after the code email now
voids the approval, since the hash binds the approval's own tip and
could never see the snapshot move; the email promised any change
voids. And not_found renders as what it is instead of falling through
to the placed face.
@mcheemaa

Copy link
Copy Markdown
Owner Author

Triage of the 19 findings, with thanks; this was a genuinely useful pass.

Landed in 0a7e38f (the three that matter before real money moves):

  • Transport failure recorded as failed with checkout advice: submit timeouts and process deaths now raise a dedicated uncertainty error; the tool reconciles against order history (an order at this store our ledger has never seen is the lost response), adopts it into the normal status poll, or records an honest unknown that tells everyone not to resubmit and not to check out elsewhere.
  • Tip edited after the code email passing revalidation: the snapshot tip is now compared directly and any mismatch voids the approval. The charge could never drift (submit always sends the approval's frozen tip), but the card and the email's promise are now honest too.
  • not_found rendering as "Placed the order": it now has its own face.

Landed in d918ca0: the daily approval budget is env-tunable.

Queued next, before any shared/guest use of the instance: atomic daily-order reservation, blank-env caps parsing (Number("") === 0), sealed-cart-while-placing (mutation gate during submit), cancel-route atomicity and an unguessable capability on the approval routes, void-on-email-failure, pickup availability check, the null-menu-id shrink guard, run cancellation on ops-route timeout, pending-order reconciliation on the already-consumed path, wake-delivery retry, cart card hydration by UUID, the private: true browse face, and the missing stories.

Rejected: the handwritten-migration finding misreads the repo law. 0009 was generated via pnpm db:generate --custom, drizzle's mechanism for data backfills; the rule targets hand-editing schema-diff SQL. AGENTS.md will gain a clarifying line.

Deferred with reasoning: cart-state readback into the next agent turn. Staleness cannot reach a charge because both money steps re-read the live cart (request-approval re-prices it; submit re-reads and re-hashes it), so this is conversational polish rather than a money-path defect. It rides the same follow-up as card hydration.

@mcheemaa
mcheemaa merged commit d259318 into main Aug 11, 2026
7 checks passed

This branch was successfully deployed

1 active deployment
Preview — 0a7e38f0 Deployed Aug 11, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant