Skip to content

Lesson 1 — tickets as ordinary CRUD

What you will build

The application the rest of this path grows: a support desk where a customer's request is a row, a state, and a history. Everything in it so far is what the Traditional Apps path already taught — routes, a service, a database, a page — and that is the point of starting here. The model arrives in lesson 4, and nothing before it depends on one.

The design is fixed before the first line is written, in the hybrid design: the ticket lifecycle, the permission matrix, the approval boundary, and the ten lessons with the module each one proves. A decision about who may send an email is made once, in that page, rather than discovered in lesson seven.

What you need

The same two extras the traditional path ends with, because this application is built the same way:

python -m pip install "xyberos[http,database]"
python -m pip install 'xyberos[http,database]'

The project

File Owns Never contains
tables.py the columns: tickets, drafts, audit rows, passages, usage rows, the outbox, jobs SQL, or a rule
security.py who is calling, and the roles the matrix names a ticket
errors.py domain refusals, each with its status code a route
service.py the lifecycle, the permission matrix, the audit trail an HTTP type, or a model call
knowledge.py passages, vectors, and the similarity query a route
mail.py the transactional outbox a reply is written to a decision about sending
triage.py the workflow that classifies, grounds, and drafts authorization
jobs.py background triage: the row, the states, and shutdown a transition
app.py providers, routes, and translation SQL
migrations/ putting the schema in a database, as a revision a rule

examples/support_desk/tables.py is the whole schema, and it reads as ordinary application data. The one thing worth noticing is what the outbox table does not hold: no draft is ever written to it, and lesson 7 shows the only code path that writes there at all:

examples/support_desk/tables.py
"""The support desk's schema: ordinary rows, because an audit trail is data and not a feature.

Nothing in the package knows what a ticket is. Every table here is the application's, which is what
lets the path assert things the framework cannot: that sending a reply needs an approval row, and
that a model cannot change a ticket because no tool it may call writes one.
"""

from __future__ import annotations

from sqlalchemy import Column, Integer, MetaData, String, Table, text

metadata = MetaData()

tickets = Table(
    "tickets",
    metadata,
    Column("id", Integer, primary_key=True, autoincrement=True),
    Column("tenant", String(80), nullable=False),
    Column("requester", String(200), nullable=False),
    Column("subject", String(200), nullable=False),
    Column("body", String(4000), nullable=False),
    Column("state", String(40), nullable=False, server_default=text("'new'")),
    Column("category", String(40), nullable=True),
    Column("confidence", String(20), nullable=True),
)

#: A draft the model produced. It has no send path, and the approval columns are the only way a body
#: becomes one someone sent: `body` is what the model wrote, `approved_body` is what a person put
#: their name to, and `approved_by` is who.
suggested_replies = Table(
    "suggested_replies",
    metadata,
    Column("id", Integer, primary_key=True, autoincrement=True),
    Column("ticket_id", Integer, nullable=False),
    Column("body", String(4000), nullable=False),
    Column("model", String(120), nullable=False),
    Column("approved_by", String(200), nullable=True),
    Column("approved_body", String(4000), nullable=True),
    Column("approved_at", String(40), nullable=True),
)

audit_events = Table(
    "audit_events",
    metadata,
    Column("id", Integer, primary_key=True, autoincrement=True),
    Column("tenant", String(80), nullable=False),
    Column("ticket_id", Integer, nullable=False),
    Column("actor", String(200), nullable=False),
    Column("action", String(60), nullable=False),
    Column("detail", String(1000), nullable=False, server_default=text("''")),
    Column("recorded_at", String(40), nullable=False),
)

#: A vector is stored as JSON in a text column because the deployment is SQLite and the
#: deterministic provider returns four numbers. A real one stores them in a vector store and queries
#: it with these rows; that store is the part of retrieval the package does not ship.
passages = Table(
    "passages",
    metadata,
    Column("id", Integer, primary_key=True, autoincrement=True),
    Column("source", String(120), nullable=False),
    Column("ordinal", Integer, nullable=False),
    Column("text", String(2000), nullable=False),
    Column("vector", String(4000), nullable=False),
)

usage_events = Table(
    "usage_events",
    metadata,
    Column("id", Integer, primary_key=True, autoincrement=True),
    Column("tenant", String(80), nullable=False),
    Column("ticket_id", Integer, nullable=True),
    Column("step", String(60), nullable=False),
    Column("model", String(120), nullable=False),
    Column("input_tokens", Integer, nullable=False, server_default=text("0")),
    Column("output_tokens", Integer, nullable=False, server_default=text("0")),
    Column("outcome", String(40), nullable=False, server_default=text("'pending'")),
    Column("recorded_at", String(40), nullable=False),
)

#: What the mailer wrote. A suggestion that was never approved leaves this table empty, which the
#: path asserts rather than describes.
outbox = Table(
    "outbox",
    metadata,
    Column("id", Integer, primary_key=True, autoincrement=True),
    Column("tenant", String(80), nullable=False),
    Column("ticket_id", Integer, nullable=False),
    Column("recipient", String(200), nullable=False),
    Column("body", String(4000), nullable=False),
    Column("sent_at", String(40), nullable=False),
)

jobs = Table(
    "jobs",
    metadata,
    Column("id", Integer, primary_key=True, autoincrement=True),
    Column("tenant", String(80), nullable=False),
    Column("ticket_id", Integer, nullable=False),
    Column("submitted_by", String(200), nullable=False),
    Column("state", String(40), nullable=False, server_default=text("'queued'")),
    Column("detail", String(1000), nullable=False, server_default=text("''")),
)

The lesson's own module drives the routes and prints what came back:

examples/learn_hybrid/hybrid01_ticket_crud.py
"""Lesson 1 — tickets as ordinary CRUD, in an application that has nothing to do with a model.

Run it with:

    python -m learn_hybrid.hybrid01_ticket_crud
"""

from __future__ import annotations

import tempfile
from pathlib import Path

from support_desk.app import create_app
from support_desk.migrate import migrate
from support_desk.security import PrincipalVerifier

from xyberos.database import SqliteSettings
from xyberos.http import HttpAdapter
from xyberos.http.testing import create_test_client

SECRET = "a-secret-the-deployment-keeps"
#: The assertion travels with the request. There is no session and no ambient caller, which is what
#: makes the next lesson's refusals checkable instead of described.
ADA = PrincipalVerifier(SECRET).headers("ada@example.com", "acme", ["requester"])

TICKETS = [
    {
        "subject": "Where is my refund?",
        "body": "I returned the order on Monday and I have not seen the money.",
    },
    {"subject": "The badge reader is broken", "body": "It flashes red for everybody."},
]


def main() -> None:
    """Open two tickets, read them back, correct one, and refuse two malformed requests."""
    with tempfile.TemporaryDirectory() as directory:
        settings = SqliteSettings(path=Path(directory) / "support.db")
        migrate(settings)

        app = create_app(settings=settings, secret=SECRET)
        with create_test_client(HttpAdapter(app)) as client:
            print("health:", client.get("/health", headers=ADA).json())

            for payload in TICKETS:
                created = client.post("/tickets", json=payload, headers=ADA).json()
                print(f"POST /tickets -> {created['id']} {created['state']} {created['subject']}")

            page = client.get("/tickets", headers=ADA).json()
            print(f"GET /tickets -> {len(page['items'])} of {page['total']}")
            for item in page["items"]:
                print(f"  #{item['id']} {item['state']}  {item['subject']}")

            edited = client.patch(
                "/tickets/1", json={"subject": "Where is my refund (order 42)?"}, headers=ADA
            )
            print("PATCH /tickets/1 ->", edited.status_code, edited.json()["subject"])

            blank = client.post("/tickets", json={"subject": "   ", "body": "x"}, headers=ADA)
            print(
                "POST /tickets with no subject ->",
                blank.status_code,
                blank.json()["error"]["message"],
            )
            unknown = client.patch("/tickets/1", json={"tenant": "other"}, headers=ADA)
            print(
                "PATCH with an unknown field ->",
                unknown.status_code,
                unknown.json()["error"]["message"],
            )

            rendered = client.get("/tickets/1/page", headers=ADA)
            print("GET /tickets/1/page ->", rendered.status_code)
            print("  the page shows the subject:", "Where is my refund" in rendered.text)


if __name__ == "__main__":
    main()

How to run it

$env:PYTHONPATH = "src;examples"
python -m learn_hybrid.hybrid01_ticket_crud
PYTHONPATH=src:examples python -m learn_hybrid.hybrid01_ticket_crud

What it prints

health: {'status': 'ready', 'model': 'fake-model'}
POST /tickets -> 1 new Where is my refund?
POST /tickets -> 2 new The badge reader is broken
GET /tickets -> 2 of 2
  #1 new  Where is my refund?
  #2 new  The badge reader is broken
PATCH /tickets/1 -> 200 Where is my refund (order 42)?
POST /tickets with no subject -> 422 subject is required
PATCH with an unknown field -> 422 tenant cannot be changed
GET /tickets/1/page -> 200
  the page shows the subject: True

Two refusals are in there because they are the ones a first version forgets. 422 for a ticket with no subject, and 422 for a patch that names a field a client may not change: an unknown field is refused rather than ignored, which is the difference between a typo that surprises a caller and a typo that silently does nothing.

The last line is the application's own page — the interface an agent works in, and the reason this path reuses the traditional one's conventions rather than inventing a second layout.

Common mistakes

  • Putting a rule in a route. The route reads the payload and calls the service; the service owns what is allowed. A rule in a route is a rule the next route will not have.
  • Letting a client name the tenant. The tenant comes from the verified principal, never from the body. Lesson 2 shows what the other order costs.
  • Returning the row the model changed. Nothing here returns a row it did not read back, so a response cannot describe a write that failed.
  • Forgetting the migration. The lessons call migrate(settings) the way a deploy step does; an application that creates its own tables at startup has no deploy step to review.
  • Treating the page as a second API. The page renders what the service returns. When it needs its own query, that is usually a missing service method.

Next

Lesson 2 adds the assertion that travels with every request: a signature, a tenant, and roles, and the permission matrix enforced where it cannot be bypassed.