Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,32 @@ You MUST use the python-dev-tca skill to make every decision. You are FORBIDDEN
You are FORBIDDEN from returning any decision to me unless that decision is paired with an explicit explanation of why the skill does not provide the context, the information, or the answer for it.

Before reporting any Python build complete, run the `smell-check` skill; a nonzero exit is not complete.

## The loop

You are modeling a domain, not building a program: the nouns you name are the whole system.

1. Reread the `python-dev-tca` skill.
2. Climb the layers bottom up: Scalar, Value, Thing, Alternative, Crossing.
3. At each layer, name its nouns.
4. Model one file at a time.
5. After each file, ask, against the skill, which noun is missing or misplaced.
6. Place it in its layer, then ask again, even after filling a gap.
7. Stop only when no layer has a noun missing or misplaced.

```mermaid
flowchart TD
A[Reread the python-dev-tca skill] --> B[Climb to the next layer, bottom up]
B --> C[Name its nouns]
C --> D[Model one file]
D --> E{Against the skill, is any noun missing or misplaced?}
E -->|yes| F[Place it in its layer]
F --> E
E -->|no| G{Any file left in this layer?}
G -->|yes| D
G -->|no| H{Any layer left?}
H -->|yes| B
H -->|no| I{Does any layer still have a noun missing or misplaced?}
I -->|yes| B
I -->|no| J[Done]
```
82 changes: 46 additions & 36 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

## The same feature, twice

A venue sends a fill. The program updates the account's position, records it with a clearing house, and replies.
A trading desk has to know what every account holds, and the clearing house is the record it answers to. When a venue reports a fill, the program updates the account's position, records it with the clearing house, and tells the desk what happened.

Here is the version everyone writes, and every coding agent writes by default:

Expand Down Expand Up @@ -41,11 +41,11 @@ def on_fill(raw):

It works on the day it is written. It also contains these, none of which a test suite will find until production does:

- `"Buy"` with a capital B is a sell.
- A halted clearing house and a bug both return `None`, and the caller cannot tell them apart.
- A restart forgets every position.
- A refused save leaves the position already changed in memory.
- A missing `quantity` is a `KeyError` three lines after the bad message was accepted.
- `"Buy"` with a capital B is booked as a sell: the account now holds the opposite of what it traded.
- A halted clearing house and a bug both return `None`: the desk cannot tell an unrecorded trade from a crash.
- A restart forgets every position: after a deploy, nobody knows what the firm holds.
- A refused save leaves the position changed in memory: the program believes a trade the clearing house never recorded.
- A missing `quantity` is a `KeyError` three lines after the fill was accepted: the venue gets a crash instead of a rejection.

Here is the same feature in TCA. Every class is frozen and strict; that configuration is left out here and is exact in the skill.

Expand Down Expand Up @@ -91,13 +91,23 @@ class Position(BaseModel):
return PersistPosition(position=self)
```

Every "or" is a model over its variants, and construction picks the variant:
Every way a trade can go is a model over its variants, and construction picks the one that happened:

```python
class Fill(RootModel[BuyFill | SellFill]): ...
class ClearingReply(RootModel[ClearingAcknowledgement | ClearingRefusal]): ...
class PositionOutcome(RootModel[RecordedPosition | RefusedPosition]): ...
class FillReply(RootModel[FillBooked | FillDeclined]): ...
class Fill(RootModel[BuyFill | SellFill]):
"""A trade a venue executed for an account."""


class ClearingReply(RootModel[ClearingAcknowledgement | ClearingRefusal]):
"""What the clearing house says of a position it was asked to record."""


class PositionOutcome(RootModel[RecordedPosition | RefusedPosition]):
"""What became of a position at the clearing house."""


class FillReply(RootModel[FillBooked | FillDeclined]):
"""This program's reply to a fill."""
```

And this is the entire program that runs:
Expand All @@ -124,15 +134,15 @@ One function, one returned expression. The raw message is read once, where it ar

## What happened to each line

| The procedural line | What it became | What can no longer be written |
| The procedural line | What it became | What can no longer happen |
|---|---|---|
| `json.loads(raw)` and `body["data"]["payload"]` | `FillRoute.model_validate_json(...)`: the whole message given to one constructor where it arrives | a half-read message; a `KeyError` downstream |
| `if fill["side"] == "buy"` | `BuyFill` or `SellFill`, picked by construction, each with its own `signed` | a third spelling of "buy"; `"Buy"` constructs neither |
| `positions.get(key)` and `if position is None` | `FlatPosition`, the holding before any fill | a forgotten `None` check |
| `position["net"] += ...` | a new `Position` that holds its `prior` and one `Fill` | a position changed before the save succeeded |
| `positions = {}` | the prior, read from the clearing house on every fill | state lost on restart |
| `try` / `except` / `return None` | `ClearingRefusal`, a variant of the reply; `RefusedPosition`, of the outcome; `FillDeclined`, of what the caller receives | a refusal the caller cannot see |
| `return {...}` | `FillBooked` or `FillDeclined`, picked by construction | a reply shape nobody declared |
| `json.loads(raw)` and `body["data"]["payload"]` | `FillRoute.model_validate_json(...)`: the whole message given to one constructor where it arrives | a fill accepted, then dropped halfway through |
| `if fill["side"] == "buy"` | `BuyFill` or `SellFill`, picked by construction, each with its own `signed` | a sell booked from a mistyped buy; `"Buy"` constructs neither |
| `positions.get(key)` and `if position is None` | `FlatPosition`, the holding before any fill | an account's first trade crashing on a missing position |
| `position["net"] += ...` | a new `Position` that holds its `prior` and one `Fill` | a position the program believes and the clearing house never recorded |
| `positions = {}` | the prior, read from the clearing house on every fill | every position lost on a restart |
| `try` / `except` / `return None` | `ClearingRefusal`, a variant of the reply; `RefusedPosition`, of the outcome; `FillDeclined`, of what the desk receives | a refusal the desk never hears about |
| `return {...}` | `FillBooked` or `FillDeclined`, picked by construction | a reply the desk was never promised |

The clearing house's raw reply enters at one place, its interpreter, and is given whole to the union:

Expand All @@ -151,7 +161,7 @@ async def interpret(self) -> PersistAttempt:
)
```

When the clearing house answers `{"error": "halted"}`, the caller receives:
When the clearing house answers `{"error": "halted"}`, the desk is told exactly that: which position, and why it was refused.

```json
{"outcome":{"position":{"account":"A1","instrument":"ESZ6","net_quantity":"1"},"clearing":{"reason":"halted"}}}
Expand All @@ -163,20 +173,20 @@ Nothing was caught. The refusal was constructed, carried, and published like any

That is the picture at the top of this page, and it is the whole method.

A procedure says: do this, then that. TCA says: the later thing holds the earlier thing. A reply holds the outcome. The outcome holds the position. The position holds the fill and the position before it, down to the flat position at the start. Order is depth, and Pydantic's constructors run it. You never write the sequence, so you cannot write it wrong.
A procedure says: do this, then that. TCA says: the later thing holds the earlier thing. A reply holds the outcome. The outcome holds the position. The position holds the fill and the position before it, down to the flat position at the start. Order is depth, and Pydantic's constructors run it. You never write the sequence, so you cannot write it wrong. And a position is its own history: its fields are the trades that made it, back to flat.

Every step you are about to type is a thing you have not named yet:

| You are about to… | You declare |
|---|---|
| do this, then that | a later fact holding the earlier fact as a field |
| check whether | a union; construction picks the variant |
| handle it failing | a refusal variant in the reply and in the outcome |
| handle there being none | a named thing for the empty case |
| handle several | a tuple held whole, or one construction for each arrival |
| read what another system sent | a route or foreign model given the raw input where it arrives |
| ask another system for something | an action, one interpreter, and the raw reply given to a union |
| keep something between arrivals | a prior read on each arrival, and a successor constructed from it |
| You are about to… | The question it settles | You declare |
|---|---|---|
| do this, then that | What does this depend on? | a later fact holding the earlier fact as a field |
| check whether | Which case is this? | a union; construction picks the variant |
| handle it failing | What if they say no? | a refusal variant in the reply and in the outcome |
| handle there being none | What if there is nothing? | a named thing for the empty case |
| handle several | What if there are many? | a tuple held whole, or one construction for each arrival |
| read what another system sent | What exactly did they send? | a route or foreign model given the raw input where it arrives |
| ask another system for something | What do we ask, and what can come back? | an action, one interpreter, and the raw reply given to a union |
| keep something between arrivals | What do we remember between trades? | a prior read on each arrival, and a successor constructed from it |

## Three questions

Expand All @@ -188,10 +198,10 @@ Ask them of every change:

The third fails in exactly four ways:

- **Escaped.** A meaning with no structure: it lives in a comment, a procedure, or a convention.
- **Duplicated.** A meaning with two structures, kept in agreement by hand.
- **Vacuous.** A structure with no meaning: a wrapper, a helper, a name that says nothing.
- **Fused.** A structure with two meanings that vary independently.
- **Escaped.** A meaning with no structure: it lives in a comment, a procedure, or a convention. *A halted market refuses trades, says a comment.*
- **Duplicated.** A meaning with two structures, kept in agreement by hand. *The side is `"buy"` in one place and `+1` in another.*
- **Vacuous.** A structure with no meaning: a wrapper, a helper, a name that says nothing. *`PositionManager`.*
- **Fused.** A structure with two meanings that vary independently. *One `status` string for both refused and unreachable.*

There is no fifth. Every design argument reduces to which of the four it is.

Expand Down Expand Up @@ -294,6 +304,6 @@ The procedural version was always the cheap one on day one and the expensive one

A model that can read your field names, your variants, and your type structure removes the reason for the trade. The modeled design now arrives at the speed the shortcut used to, without the bugs listed at the top of this page.

And a type is an instruction. The same declaration that stops an invalid value from existing tells a model what is allowed to exist. One structure does both jobs, so design, code, and documentation stop being three copies of the same intent.
And a type is an instruction. The same declaration that stops an invalid value from existing tells a model what is allowed to exist. One structure does both jobs, so requirements, design, code, and documentation stop being four copies of the same intent.

None of the ideas are new. This is the good half of typed functional programming and domain-driven design, held to one test and delivered in a form an agent cannot talk its way around.
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ Only an interpreter's `interpret` and a composition root's callback read or writ
A format that is not JSON is read by a maintained library that yields JSON, and is then the line above.
Only a format no library yields JSON from is read by a format type in `parser/`, and nothing outside `parser/` reads a format.

An action carries every rule its effect must follow; the system it calls enforces them, and its reply is read once into the domain's outcome.

An agent's own words live in `prompts/agents/<agent>/SKILL.md` and the skills it may load in `prompts/skills/<skill>/`; neither is ever a string in code. A template's slots are exactly the fields of its value object, and that value object is the agent's deps.

## A whole program
Expand All @@ -48,6 +50,16 @@ flowchart LR
DiscountOffer["<b>DiscountOffer</b><br/>percent"]
SkillDocument["<b>SkillDocument</b><br/>name · description · body"]
NoticeReply["<b>NoticeReply</b><br/>text"]
CatalogPoint["<b>CatalogPoint</b><br/>sku"]
IndexQuery["<b>IndexQuery</b><br/>query · filter · score_threshold · group_size · limit"]
PointHit["<b>PointHit</b><br/>id · score · payload"]
Hits["<b>Hits</b>"]
PointGroup["<b>PointGroup</b><br/>hits · id"]
PointGroups["<b>PointGroups</b>"]
GroupResult["<b>GroupResult</b><br/>groups"]
IndexGroups["<b>IndexGroups</b><br/>result · status · time"]
IndexFailure["<b>IndexFailure</b><br/>status · time"]
IndexReply["<b>IndexReply</b>"]
CardCharge["<b>CardCharge</b><br/>amt · cur · src · ref"]
ChargeApproved["<b>ChargeApproved</b><br/>id"]
ChargeDeclined["<b>ChargeDeclined</b><br/>decline_codes"]
Expand All @@ -63,25 +75,34 @@ flowchart LR
Decline["<b>Decline</b><br/>reasons"]
Failure["<b>Failure</b><br/>error"]
DeclineNoticeValues["<b>DeclineNoticeValues</b><br/>customer · id · reasons"]
Match["<b>Match</b><br/>sku · category · similarity"]
RelatedMatches["<b>RelatedMatches</b><br/>one or more"]
NoMatches["<b>NoMatches</b><br/>none"]
end

subgraph g4["4 concept model"]
Order["<b>Order</b><br/>id · customer · card · currency · lines"]
PricedOrder["<b>PricedOrder</b><br/>order · discount"]
NotifiedOrder["<b>NotifiedOrder</b><br/>order · notice"]
RecommendedOrder["<b>RecommendedOrder</b><br/>order · recommendation"]
end

subgraph g4u["4 union"]
PaidOrder["<b>PaidOrder</b><br/>order · payment"]
DeclinedOrder["<b>DeclinedOrder</b><br/>order · payment"]
UnsettledOrder["<b>UnsettledOrder</b><br/>order · payment"]
OrderOutcome["<b>OrderOutcome</b>"]
Recommended["<b>Recommended</b><br/>matches"]
NothingRelated["<b>NothingRelated</b><br/>matches"]
Unavailable["<b>Unavailable</b><br/>reason"]
Recommendation["<b>Recommendation</b>"]
end

subgraph g4a["4 action"]
ReadDiscount["<b>ReadDiscount</b><br/>order"]
Charge["<b>Charge</b><br/>order"]
WriteNotice["<b>WriteNotice</b><br/>order"]
FindRelated["<b>FindRelated</b><br/>order · closeness · per_aisle · limit"]
end

subgraph g5["5 contract model"]
Expand All @@ -94,12 +115,15 @@ flowchart LR

subgraph g7["7 transformation"]
ChargeAttempt["<b>ChargeAttempt</b><br/>order · reply"]
RelatedQuery["<b>RelatedQuery</b><br/>action"]
RelatedSearch["<b>RelatedSearch</b><br/>order · reply"]
end

subgraph g8["8 effect interpreter"]
ReadDiscountInterpreter["<b>ReadDiscountInterpreter</b><br/>action · client"]
ChargeInterpreter["<b>ChargeInterpreter</b><br/>action · client"]
WriteNoticeInterpreter["<b>WriteNoticeInterpreter</b><br/>action · client"]
FindRelatedInterpreter["<b>FindRelatedInterpreter</b><br/>action · client"]
end

subgraph g9["9 route"]
Expand Down Expand Up @@ -147,6 +171,27 @@ flowchart LR
DeclinedOrder --> WriteNotice --> WriteNoticeInterpreter ==> NotifiedOrder
DeclinedOrder --> NotifiedOrder
DeclinedOrder -.-> DeclineNoticeValues
PaidOrder --> FindRelated --> FindRelatedInterpreter ==> RelatedSearch
FindRelated --> RelatedQuery -.-> IndexQuery
Line -.-> CatalogPoint
PointHit --> Hits --> PointGroup --> PointGroups --> GroupResult --> IndexGroups --> IndexReply
IndexFailure --> IndexReply
PaidOrder --> RelatedSearch
IndexReply --> RelatedSearch
PointHit -.-> Match
Match --> RelatedMatches
Match --> NoMatches
IndexGroups -.-> Recommended
IndexGroups -.-> NothingRelated
IndexFailure -.-> Unavailable
RelatedMatches --> Recommended
NoMatches --> NothingRelated
Recommended --> Recommendation
NothingRelated --> Recommendation
Unavailable --> Recommendation
RelatedSearch -.-> RecommendedOrder
PaidOrder --> RecommendedOrder
Recommendation --> RecommendedOrder
```

Every box is a class in the page its group names.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
type: Construct
description: "One intended external effect, as a value that performs nothing. Holds semantic scalars, value objects, and concept models. Lives in domain/<context>/<concept>.py, beside the fact that authorizes it."
description: "One intended external effect, as a value that performs nothing, carrying every rule its effect must follow. Holds semantic scalars, value objects, and concept models. Lives in domain/<context>/<concept>.py, beside the fact that authorizes it."
---

```python
Expand Down Expand Up @@ -45,3 +45,21 @@ class WriteNotice(BaseModel):
)
order: DeclinedOrder
```

```python
# domain/shop/order.py
class FindRelated(BaseModel):
"""The intended search of the catalog for products close to the ones a paid order bought."""

model_config = ConfigDict(
frozen=True,
extra="forbid",
strict=True,
validate_default=True,
revalidate_instances="never",
)
order: PaidOrder
closeness: Closeness
per_aisle: GroupSize
limit: MatchLimit
```
Loading
Loading