AI AI & agentic architectures

When the user changes their mind mid-task

Stopping the agent doesn't stop what it already sent. Secure the new plan, then undo the old one.

On this page · 12 sections

You’ve asked your agent to book dinner for four on Friday at 19:00, and to text the group, the friends you’re eating with, once the table is confirmed. The agent’s runtime, the code around the model that calls tools and keeps the history, has sent the booking request to the restaurant’s system and is waiting for the reply. Before it arrives, you write: “actually, Saturday”.

The obvious move is to stop what the agent is doing and start again with the new plan. Here’s what that does, in the small runtime from the previous article. R1 and R2 are the restaurant’s first and second reservations:

text
python -m agent_runtime.demo revise-in-flight-naive
-- restaurant holds: R1 (Fri 19:00), R2 (Sat 19:00)
-- group received: 'table for 4, Sat 19:00'
-- agent's last message: Booked Sat 19:00 for 4 (R2). Texted group.

Two tables, and the agent reports success. Worse, its history, the only account of the work it will ever read, never mentions the Friday table, so nothing will ever cancel it. You changed your mind; the world didn’t. The Friday request had already reached the restaurant. I’ll show what a runtime has to do instead, one failure at a time, with the same booking all the way through.

The short version

  • Stopping the agent stops it waiting for the restaurant. It doesn’t stop the restaurant. A request already sent is in doubt until the agent finds out what happened to it.
  • So ask the restaurant whether Friday went through, and tell it to refuse that request if it turns up late. If the service can’t be asked, wait for its reply instead.
  • Give each booking its own ID, so the restaurant never confuses Saturday with Friday.
  • Book Saturday first, then cancel Friday. The other order can leave you with no table at all.
  • Write each request down before sending it, so a crash in the middle can be recovered without booking twice.
  • Then tell you, and the agent’s own history, what happened to Friday.

The assistant on hold#

Think of a personal assistant on hold with the restaurant, booking Friday, when you lean over and say “actually, make it Saturday”. A careless assistant hangs up and calls back about Saturday, and you end up with two tables. A good one asks the restaurant first: did that Friday booking go through? Then they book Saturday, and only once Saturday is confirmed, cancel Friday. And they tell you about both.

That’s the whole procedure, and the rest is how a runtime does each part when the “restaurant” is an API and the “assistant” is code.

Every run here comes from the previous article’s runtime, where a scripted model reads each phrase into a fixed result, so every difference between two runs comes from the runtime’s rules. In its logs, T1 is the first turn, one round of plan, act and reply, A1 the first consent request, and ... marks lines left out. IDs restart in each run, so R1 is whichever table that run booked first.

Four things a revision has to keep apart#

A revision touches four different things, and they change at different moments:

A grid with four rows and three columns. Rows: input, intent, pending, and effects in the world. Columns: before, revision arrives, reconciled. Before: the input is book Friday 19:00 for 4 and tell the group, the intent is Friday 19:00 for 4, the Friday reservation is pending with no reply yet, and in the world R1 is committed for Friday, not yet heard by the runtime. When the revision arrives: the input is actually, Saturday, the intent is Saturday 19:00 for 4, the Friday reservation is in doubt, and R1 is still committed and still not heard. Reconciled: nothing new arrives, the intent is Saturday, nothing is pending, and in the world R1 is cancelled, R2 is booked for Saturday, and the group has been texted Saturday.
The intent changes the moment the user types. The world doesn’t change at all: R1 exists whatever the user now wants.

The runtime can’t see the bottom row directly. It only learns what happened in the world from replies, lookups and receipts. So the third row, pending, what it has sent without hearing back, decides its next move. A revision is reconciled once the runtime’s records and plan agree with the world again.

Those rows live in different parts of the runtime. The figure names them, and each one comes up below:

Component diagram of the runtime in three bands and a store column. The user's revision, actually, Saturday, enters Admission in the Session band, which acts on one input at a time and stores the input in the Inbox, part of the Store, before the ack. Admission passes the words to the scripted model, which reads the relation and hands revises: day=Sat to Revision in the Turn band. Revision consults the tool contracts on whether to wait or resolve, closes key s1:op-a2 at the Restaurant in the Services band, and starts T2 in the Turn runner. The Turn runner writes each operation to the Operations log in the Store, written before each send together with the releases owed, reserves and cancels at the Restaurant, cancelling R1 only after R2 holds, and sends the correction through Messages, which has no lookup by key.
On a revision, the old call is settled with the restaurant before the new turn sends anything, and each new operation is in the store before its request goes out.

Reading the revision#

First the runtime has to know what “actually, Saturday” means. The model turns it into a structured revision of the running turn, revises T1: day=Sat. The runtime can’t catch a misreading; it acts on whatever the model reports. But for something unclear, like “Saturday too?”, which the model marks unsure, the runtime lets the call in flight finish and asks: “Do you mean Sat instead of Fri, or as well?”

With the new intent known, the runtime compares every booking the old turn holds against it:

python
agent_runtime/runtime.py
if op.step == "reserve_table" and op.state == "committed":
op.release = False
if self._matches(op.args, new):
kept.append(op.id)
...
else:
op.release = True
release.append(op.id)
...
@staticmethod
def _matches(args, intent):
return (args["day"], args["time"], args["party"]) == (intent.day, intent.time, intent.party)

A booking that still matches the new intent is kept. One that doesn’t is marked for release: it will be cancelled once its replacement holds, not now. “Actually, make it 20:00” doesn’t match R1, so R1 is released and 20:00 is booked. “Actually, just tell Sam, not the group” does match, so R1 is kept and the new plan skips booking altogether. What counts as “the same booking” is _matches: add a field there, and every revision compares it.

Stopping the wait doesn’t stop the work#

In the run at the top, the revision stopped the code that was waiting for the restaurant’s reply, and the log says so:

text
python -m agent_runtime.demo revise-in-flight-naive
5 tool.sent T1 reserve_table(Fri, 19:00, 4) key=s1:op-a2
6 restaurant committed R1 Fri 19:00 x4
7 input user: actually, Saturday
8 model.read revises T1: day=Sat
9 tool.cancelled T1 reserve_table: stopped waiting

Line 9 claims a cancellation the runtime has no evidence for. The restaurant committed R1 at line 6. None of the usual stop buttons reach the restaurant:

  • Cancelling a Python asyncio task only arranges for a CancelledError “to be thrown into the wrapped coroutine”: it stops your code.
  • OpenAI’s mid-turn steering “does not rewrite output already sent to your application, undo earlier actions, or cancel tools that have already started”.

Both stop the agent’s own work. The table stays booked. In the assistant’s terms, putting the phone down doesn’t undo what the restaurant already wrote in its book. So the reconciled runtime records the same moment differently. When a revision stops the wait, it marks any call that has an effect on the world as unknown, not cancelled:

python
agent_runtime/runtime.py
except asyncio.CancelledError:
if not self.dead:
op.state = "unknown" if self.reconcile and c.effect != "none" else "cancelled"
self.tl.add("tool." + op.state, f"{op.turn} {op.step}: stopped waiting")
raise

reconcile=False is the run at the top. (The previous article’s restart logged tool.unknown at this point because it had this switch on; the run at the top turns it off.) With it on, a call with an effect is in doubt (unknown in the code and logs) until something resolves it. A pure read, like checking availability, has effect="none" and can simply be dropped.

The model API adds a rule of its own here

Anthropic’s tool-use documentation says “Tool result blocks must immediately follow their corresponding tool use blocks”. So the user’s revision can’t be added to the history until the open call has a result, and at that moment the only honest result is “in doubt”. The demo’s history meets the rule because, as the last section shows, it records each result right after its own call, where its effect happened, and only then the revision.

Closing the key#

Every booking request carries an idempotency key, key= in the logs: an ID that lets the restaurant perform the request at most once, however many times it arrives. Here it’s s1:op-a2, session s1, operation op-a2. The restaurant can say, by key, whether a request landed, and the runtime asks it when it resolves an in-doubt call:

python
agent_runtime/runtime.py
def _resolve(self, op):
c = self.contracts[op.step]
if c.lookup and op.key:
if c.fence and self.fencing:
rid = self.restaurant.close(op.key)
self.tl.add("resolve", f"{op.key}: {rid or 'nothing has landed'}; key closed")
else:
rid = self.restaurant.lookup(op.key)
self.tl.add("resolve", f"{op.key}: {rid or 'nothing has landed'}")
...

There are two ways to ask. lookup just asks “did this key land?”. close asks the same and, in one step, tells the restaurant to refuse that key from now on: the assistant adding “and if a Friday request from me turns up later, ignore it”. fence in the tool’s contract says the restaurant supports closing, and fencing is the runtime’s switch for using it. The difference matters because a lookup that finds nothing only proves the request hasn’t landed yet. Requests can be delayed in the network.

Try it. Choose Reconciles with the restaurant first, then Is delayed in the network, and compare closing the key with only looking it up. (In the delayed runs Saturday is booked first, so it’s R1, and the late Friday is R2.)

“Actually, Saturday”live lab

This lab runs a real call in your browser, so it needs JavaScript.

With only a lookup, the runtime finds nothing, books Saturday, and then Friday lands anyway: two tables. With the key closed, the late request gets refused a late request: key s1:op-a2 is closed, and the restaurant ends with only Saturday:

RestaurantNetworkRuntimeRestaurantNetworkRuntimethe request is delayedthe user revises to Saturdayreserve Fri, key s1:op-a2close key s1:op-a2nothing, key closedreserve Satthe Fri request lands laterefused, key closed

The close turns the late Friday request into a refusal; a lookup alone would have let it commit.

It only works if the restaurant records the key and the booking together, so no request can slip in between checking a key and closing it. Without a close, the fallback is time: keep the call in doubt until a deadline the service enforces has passed.

Here’s the undelayed revision done right, from start to finish:

RestaurantRuntimeUserRestaurantRuntimeUsercommits R1, reply still on its waybook Fri 19:00, tell the groupreserve Fri, key s1:op-a2actually, Saturdayclose key s1:op-a2R1, key now closedreserve Sat, key s1:op-a4R2cancel R1

The runtime finds R1 before it books Saturday, and cancels R1 only after R2 holds, so the restaurant ends with one table.

Where this idea comes from, and why ordinary idempotency isn't enough

Closing a key is related to a fencing token, where the storage side “remembers that it has already processed a write with a higher token number… and so it rejects the request”. Both let the service refuse a request that arrives too late to be wanted.

Idempotency keys as services usually offer them are built for retries, not for this. Stripe’s idempotency docs describe its v1 API: a retried key gets the saved result back, and a key it has never seen is simply run as a new request. That’s right for a retry. After a revision it’s wrong, because the late Friday request carries a key the service has never seen, so it runs and books Friday.

Keys name the operation, not the attempt#

The previous article showed a key that’s too fine: built from the turn, so a restart got a new key and booked twice. The opposite mistake is a key that’s too coarse, built from the step name alone, s1:reserve_table: the assistant filing everything under “my booking”. Then Friday and Saturday share a key. On a server that can look keys up but not close them, and replays by key without checking the request’s details, the Saturday request just gets Friday’s result back. And because the runtime then compensates for the old booking, undoing it by cancelling it, it cancels the only table there is:

text
python -m agent_runtime.demo key-reuse-lenient
17 tool.sent T2 reserve_table(Sat, 19:00, 4) key=s1:reserve_table
18 restaurant replayed R1 for key s1:reserve_table
19 tool.result T2 reserve_table -> R1
20 compensate R1: the replacement holds
...
22 restaurant cancelled R1
...
-- restaurant holds: nothing

The runtime records the result as Saturday, then cancels “the old Friday booking”, R1, which is the same table. A server that does compare details, as Stripe’s does (“errors if they’re not the same”), turns this into a visible KeyMismatch, and the runtime keeps Friday and asks (key-reuse-strict). A cheap guard, which the demo doesn’t have, would catch it too: never cancel a booking whose ID the replacement just returned.

So the key names the operation: a booking whose details haven’t changed keeps its key and is never sent twice, and Saturday is a new operation with a new key, s1:op-a4.

Or wait for the call to finish#

Resolving by key needs a service that can be asked. When it can’t, the runtime can let the call finish, stop at the next step, and work with an outcome it knows. The cost is that the revision waits for the slowest call in flight. The choice is per tool, in its contract:

python
agent_runtime/tools.py
CONTRACTS = {
# A read. Safe to drop mid-flight here; a read that places a hold would not be.
"check_availability": Contract(effect="none", needs_consent=False),
"reserve_table": Contract(effect="reversible", needs_consent=True, keyed=True, lookup=True,
fence=True, undo="cancel_reservation"),
# No lookup, so a revision waits for the delivery receipt, up to a bound (50 ms in the demo).
"text_group": Contract(effect="irreversible", needs_consent=True, wait=0.05),
# Irreversible: a released table can be taken by someone else. Acts for the user, so a
# turn the user didn't start must ask; a fee needs the user's consent whoever started it.
"cancel_reservation": Contract(effect="irreversible", needs_consent=True),
...
}

Read each line as what the runtime may assume about that tool. Checking availability has no effect, so dropping it is free. Reserving is reversible, keyed, can be looked up and closed, and its undo is cancel_reservation. The group text can’t be looked up or undone, so a revision waits up to 50 ms for its delivery receipt.

When the receipt comes in time, the runtime knows the Friday text went out and plans a correction. When it doesn’t, the runtime treats the text as sent and tells the user “I couldn’t confirm it, so I’ve treated it as sent”. Assuming delivery is safe here because a “change of plan” text to people who never saw the first plan is merely odd.

Messages that overtake each other, and payments

A correction can also overtake a delayed original, so the group reads Saturday and then Friday. The receipt wait can’t prevent that, since it has just expired. The order has to come from the channel, or from a reference in the correction that the recipient’s app can order by; otherwise the risk has to be accepted and stated, as the demo does. For a payment, a double correction isn’t harmless, so an unconfirmed charge needs its own rule, or a question.

Secure the replacement, then undo the old#

With R1 confirmed and no longer wanted, the runtime has to choose what to do first. Cancel Friday first and then try Saturday, and if Saturday is full the user is left with nothing. So the default is the other order: the releases wait in the new turn’s release list, and the runtime works through them only after the new booking succeeds. If Saturday is full, R1 comes off the list, and you’re asked: “You still hold R1 (Fri 19:00); keep it, or shall I cancel it?”

Try both orders with Saturday full:

Which goes firstlive lab

This lab runs a real call in your browser, so it needs JavaScript.

Securing first means holding two bookings for a while. Where that costs money or blocks something, like a payment hold against a card limit, releasing first is the better order, and the user should hear so before the runtime acts.

Undoing is a new action, not a rollback#

Cancelling R1 isn’t rewinding time. It’s a new action with its own contract, its own cost and its own ways to fail. The saga pattern, the standard name for this, says “a developer must design compensating transactions that explicitly undo changes made earlier”. Here the compensation for a booking is a cancellation, and inside the restaurant’s cancellation window it costs a fee. The runtime asks before paying it, even though the user started the turn, and the approval is bound to the amount:

text
python -m agent_runtime.demo revise-fee
20 compensate.ask cancelling R1 costs a 20 fee: A1 needs the user's consent
...
25 input user: yes
26 approval A1: runs exactly its 1 step(s)
...
28 tool.sent T3 cancel_reservation(R1, 20)
29 restaurant cancelled R1 (fee 20)

The other ways it can go:

What happensWhat the runtime does
The fee rises to 80 before the yes arrivesThe restaurant refuses to cancel above the approved 20, and the runtime asks again for 80
The user says “no, keep it”R1 is kept
The restaurant refuses outrightThe user is left holding both tables, and the runtime says exactly that
The turn is revised again before the user answersThe request was asked under the old plan, so it’s voided, and asked again if the new plan still releases the table

The hardest case is a booking service with no keys and no lookup, and a reply that never comes. The runtime can’t cancel a reservation it has no ID for, and can’t know whether it exists. It books Saturday, records Friday’s outcome as unknown, and tells the user what to watch for:

text
python -m agent_runtime.demo revise-no-lookup
-- agent's last message: I may have booked Fri 19:00: the restaurant never confirmed, and I can't check. If a confirmation arrives, tell me and I'll cancel it. Booked Sat 19:00 for 4 (R2). Texted group.

A crash in the middle#

Everything so far happens in one process. Now kill it at the worst moment: after the restaurant commits Saturday but before the runtime hears about it. A new process starts, reading the same store, the database the runtime writes its records to. What the new process can do depends on what was written there before the requests went out: in the assistant’s terms, whatever they wrote on their notepad before picking up the phone. The runtime writes down two things:

  • each operation, with its key, before its request is sent (the _op method from the previous article);
  • the running turn’s record: what it’s trying to do, and the releases it still owes, such as “cancel R1 once Saturday holds”.
python
agent_runtime/runtime.py
def _write_turn(self, turn):
"""The running turn's record: what a new process needs to resume it."""
self.store.turn = {"id": turn.id, "origin": turn.origin, "intent": turn.intent, "fixed": turn.fixed,
"correction": turn.correction, "release": turn.release, "kept": turn.kept,
"facts": turn.facts}

The record’s fields are the turn’s ID and origin, its intent and fixed steps, any correction it owes the group, the releases and kept bookings, and the facts it will report. With both records kept, the new process runs recover(): it resolves every call the log shows in flight, by key where the contract allows, then resumes the turn from its record, with the operations that belong to it (own).

python
agent_runtime/runtime.py
async def recover(self):
"""A new process, same store: resolve what was in flight, resume the turn, then
replay any input that was acknowledged but not yet acted on."""
self.tl.add("restart", f"{len(self.store.ops)} operation(s) in the log")
for op in list(self.store.ops.values()):
if op.state in ("sent", "unknown"):
self._resolve(op)
rec = self.store.turn
if rec is not None:
own = [op for op in self.store.ops.values() if op.turn == rec["id"]]
...

The Saturday booking comes back as skip reserve_table: op-a4 already committed as R2, R1 is cancelled, and the restaurant holds one table.

Now suppose the store kept the turn but not the operations: a design that saves the conversation between steps, but not the calls it has in flight. Try both:

A crash in the middlelive lab

This lab runs a real call in your browser, so it needs JavaScript.

With no record of the operations, the resumed turn doesn’t know Saturday was already booked as R2, so it books Saturday again, as R3, under a fresh key. And the release it owes points at a Friday operation that no longer exists, so R1 is never cancelled. Three tables: R1, R2 and R3.

The patterns this is, and a crash during the text

Writing the record before the request is the transactional outbox pattern (“first store the message in the database as part of the transaction”). Resuming from that log is what durable execution engines such as Temporal do: they persist each step so a workflow resumes after a crash.

A crash while the Saturday text or the Friday cancellation is in flight doesn’t send either again. The log shows the call as sent, and a sent call with no lookup is treated as done rather than repeated; the user is told it wasn’t confirmed (crash-during-text, crash-during-cancel).

What the history says, and what the user hears#

Last, the agent’s own memory, the assistant’s diary. After a reconciled revision where the Friday text had already gone out, the history the model sees reads:

text
python -m agent_runtime.demo revise-after-text --history
user: book Friday 19:00 for 4 and tell the group
tool: Fri 19:00 for 4: available
tool: booked R1 (Fri 19:00 for 4)
tool: texted group: 'table for 4, Fri 19:00'
user: actually, Saturday
tool: Sat 19:00 for 4: available
tool: booked R2 (Sat 19:00 for 4)
tool: cancelled R1
tool: texted group: 'Change of plan: table for 4, Sat 19:00'
assistant: The earlier text had already gone out. Booked Sat 19:00 for 4 (R2). Cancelled R1 (Fri 19:00). Texted group a correction.

Three rules made it read like that:

  • It keeps the user’s own words, including the revision.
  • Records of effects are never removed. A revision adds resolutions, cancellations and corrections; it doesn’t erase “booked R1”.
  • Each record sits where its effect happened. The Friday text’s receipt arrived after “actually, Saturday”, while the revision was waiting for it, yet its record sits before the revision, because that’s when the group got the text.

That last rule has a cost: writing into the past breaks a prompt cache from that point, since “Cache hits require 100% identical prompt segments”. The alternative is to append a record that says when the effect happened, which keeps the cache at the price of a less direct story. Either way, if the history is ever summarised to save space, the summary must not drop “booked R1”, or the next turn inherits the blind spot of the run at the top.

And the last line is what the user hears: what the records say, including that the Friday text had already gone out.

The contract each tool needs#

Every tool call in this runtime moves through the same states, and every way out of “in doubt” needs something from the service or the contract:

State diagram of a tool call. Planned goes to Sent once its key is stored and the request is sent. Sent goes to Committed on a reply, or to In doubt when the runtime stops waiting or the reply is lost. In doubt goes to Committed when closing the key finds it, to Failed when the key is closed and nothing landed, to Assumed sent when there is no lookup and the effect is irreversible, and to Outcome unknown when there is no lookup and the effect is reversible. Committed goes to Kept if it still matches, to Releasing if it is no longer wanted, and to Corrected if it is irreversible and now wrong; Assumed sent also goes to Corrected through a follow-up. Releasing goes to Compensated when the replacement holds and the undo is done, to Awaiting consent when the undo costs money, and to Kept when the replacement fails or the service refuses. Awaiting consent goes to Compensated when the user approves that amount, and to Kept when the user declines or the request expires.
Every way out of In doubt needs a key to close, or a rule for when there’s no lookup.

So for every tool your agent can call, write down:

  1. Its effect: none, reversible, or irreversible. A read that places a hold has an effect.
  2. How to undo or correct it: the compensating action, with its cost, cut-off and refusal cases, and which costs need the user’s consent, bound to the amount.
  3. What the service offers for calls in doubt: idempotency keys, a lookup by key, a way to close a key, a deadline after which requests are refused, and how long keys are kept.
  4. What a revision does while the call is in flight: stop and resolve it, or wait, and for how long.
  5. Which order to use: secure the replacement first or release first, and why.
  6. What the history and the user are told in each state, including “treated as sent” and “outcome unknown”.

Then store each operation, and each release owed, before the request that depends on it.

The demo#

It’s the same runtime as the previous article’s, in turn-without-the-user/. Every log here is one scenario’s output, and its README maps each section to its scenarios.

Shell
Terminal window
cd mainlog-code/turn-without-the-user
python -m agent_runtime.demo revise-in-flight-naive # the run at the top
python -m agent_runtime.demo revise-in-flight # the same revision, reconciled
python -m agent_runtime.demo revise-after-text --history
python -m pip install pytest && python -m pytest tests

It shows each rule in one process and isn’t hardened: its README lists what it doesn’t do, starting with durability, which is simulated by a Python object standing in for a database.

The run at the top ended with two tables and a history that never mentions Friday. With the contract filled in, the agent finds out what happened to Friday before it books Saturday, undoes it only once Saturday holds, and says so, in its history and to the user.

Share this article

Related posts

3 items
  • Sep 2, 2026 23 min read AI

    The agent event bus

    When one agent calls another with a sentence, the caller's model writes the callee's prompt, and a stranger's note can ride along. In a small scripted demo a bus seemed to fix that, but a typed fact sent by call was just as safe, and a bus event with a note was not. What the bus adds is that one agent being down doesn't leave another's work half done.

    AI & agentic architecturesai-agentsarchitecturedistributed-systems
  • Sep 25, 2026 15 min read AI

    Diagrams worth reading

    My agent could already write Mermaid. Diagrams worth reading took a skill that chooses the picture, keeps the facts intact, and checks the drawing before handing it back.

    AI & agentic architecturesai-agentsarchitecture
  • Oct 9, 2026 29 min read AI

    What a state machine buys an agent

    The same dinner booking, sent to Claude as a free-form tool loop and as a state machine. Per-state tool lists sent 61% fewer tokens, but with prompt caching on they cost 23% more on input, and with output counted the two came out about even. The real saving was code running the steps that need no model, 8 calls down to 2 and about 72% off the total, plus a table of every state against every event.

    AI & agentic architecturesai-agentsprompt-engineeringarchitecture
  • AI

    The agent event bus

    When one agent calls another with a sentence, the caller's model writes the callee's prompt, and a stranger's note can ride along. In a small scripted demo a bus seemed to fix that, but a typed fact sent by call was just as safe, and a bus event with a note was not. What the bus adds is that one agent being down doesn't leave another's work half done.

    ai-agentsarchitecture
    Sep 2026
  • AI

    Diagrams worth reading

    My agent could already write Mermaid. Diagrams worth reading took a skill that chooses the picture, keeps the facts intact, and checks the drawing before handing it back.

    ai-agentsarchitecture
    Sep 2026
  • AI

    What a state machine buys an agent

    The same dinner booking, sent to Claude as a free-form tool loop and as a state machine. Per-state tool lists sent 61% fewer tokens, but with prompt caching on they cost 23% more on input, and with output counted the two came out about even. The real saving was code running the steps that need no model, 8 calls down to 2 and about 72% off the total, plus a table of every state against every event.

    ai-agentsprompt-engineering
    Oct 2026

Comments