CRUBAL

7. Contracts

A contract is a border where values cross: to and from a system, a person, or another owner. It is the only place anything happens in the world. If something only decides, it is a rule (chapter 6); if it moves values or does something, it is a contract.

Each system declares one contract and maps only to facets, never to another system. Ten systems need ten maps, not forty-five bridges, and contracts chain without knowing each other: one contract's gives satisfies another's needs.

7.1 Kinds #

Kind Values go Examples
send out to a system a label printer, a marketplace listing, a product feed, structured data on a page
receive in from a system a scanner, a catalog lookup, an import, OCR, AI text, an ID allocator, a pricer
store both ways, between facets and an object's own fields a location, an order
form to a person and back an editor, checkout, signup, a customer's correction
offer · take between owners (7.8) a distributor's catalog, a collector's records

7.2 The long form #

contract: ebayListing
  kind: send
  label: eBay listing
  for: OpticalDisc
  needs:
    !title$80
    !format{DVD|Blu-ray|4K}
    !unit::price
  writes:
    title -> Title
    format -> aspects.Format
      4K -> "4K UHD Blu-ray"
    unit::price -> price.value
  reads:
    itemId -> ebay:itemId
  gives: ebay:itemId
  when: unit::offerStatus is listed
  who: caller wears Staff
  auth: oauth2
  scope: https://api.ebay.com/oauth/api_scope/sell.inventory
  credential: ebay-seller
  retry: 3 every 10m
  version: 2026-10-01
Part Holds For kinds
kind 7.1 all; required
label · describe what people see all
for which records it runs on: a class, a kind or a level all
needs an unnamed class (chapter 5): what must be present, and how narrow, before it may run all
gives the facets it brings back, so other contracts can chain on it receive, send, form
writes the map out (7.3) send, form
reads the map in (7.3) receive, send (its answer), form
stores the map both ways (7.3) store
when the trigger: a condition (chapter 6.3) that starts a run when it becomes true, asked for runs started by a caller, entry for a stream contract that runs as its views publish (chapter 12.6.2), or a schedule (7.11) all; default asked
missed for a scheduled contract: once, all or skip (7.11) send, receive; default once
stream the views whose projection streams the contract carries (chapter 12.6.2); it takes the place of writes and needs send
to the party the data goes to (an offer's audience, 7.8; a stream's index, chapter 12.6.2) offer, send with stream
from the presenter, by domain (7.8) take
mode · terms how shared data is held, and the presenter's terms for controlled (7.8) offer, take · offer
who who may run it: a condition over the caller (7.6) all
audience the lowest audience (chapter 12.4) that may run it: public, member, staff or owner; the menu (chapter 14.6) and who still apply all; default public for a contract with a relation (it grants the consumer's edge, chapter 14.4), member for any other form, public otherwise

A form a consumer runs on a record they do not own (a buyer cancelling their order, a member keeping their profile) needs no ownership: audience: member admits them when an edge of theirs covers the record (chapter 14.4), and a contract that grants a relation admits the caller it is about to admit. The menu and who narrow from there; nothing in a form's run reads who owns the record. | origin | the origin of the values it brings in (chapter 1.9) | receive, form, take, store; default reported for systems and declared for forms | | lands | value (the default), suggestion (7.5) or observation (chapter 9.7) | receive, form, take | | ref | the field of their payload that is their own ID for the thing; arrivals are bound by it (chapter 9.2a) | receive, form, take | | relation | the relation of the edge a run grants the caller on a record it creates for them (chapter 14.4): buyer on an order | receive, form | | evidence | with lands: observation: the evidence type (photo, scan, disc-read, scale …) | receive | | absorb · ignore · keep-raw · prefix | intake bypass, and the prefix raw fields are carried under (chapter 9.4) | receive, take | | auth · scope · credential | what the other side requires (7.6) | send, receive | | retry · undo | run handling (7.5) | send, receive | | version | the dated version | all |

A part key with a prefix is a package part, carried unchanged.

A contract's name is its identity, like a facet's key (chapter 4.1): it never renames. Bindings (chapter 9.2a), runs and log entries name it, and a renamed contract would orphan every one of them. A new label is not a rename; a contract that should go away is retired and replaced by one with a new name.

7.3 Maps #

Each map line is from -> to, in the direction values flow:

  • in writes, ourAddress -> theirField;
  • in reads, theirField -> ourAddress;
  • in stores, ourAddress -> theirField, read both ways.

Our side is an address (chapter 1.10), with the level prefix allowed (unit::price). Their side is a field path in their payload: names separated by ., a name in quotes when it holds anything but letters, digits and _ (aspects."Run Time"), [n] for an array position and [*] for every item.

Value maps are lines inside a map line, also in the direction of flow:

reads:
  aspects.Format -> format
    "4K UHD Blu-ray" -> 4K
    "Blu-ray Disc" -> Blu-ray

Our side of a value map MUST be a real value of the facet (never an alias). A value the map doesn't name passes through unchanged and is folded as usual (chapter 9): so a value map holds only what aliases and shapes can't do. A value that means "does not apply" in their vocabulary maps to -.

A field of their payload that no reads line names stays on the intake record only (outcome unknown, chapter 9): it is kept as received and searchable by staff, but nothing reaches the record. To carry a field onto the record uninterpreted, map it to a key under a prefix: aspects.Studio -> market:Studio.

A map line MAY instead go to or from a literal: "USD" -> price.currency. A contract never computes: anything that isn't a map, a value map or a literal is the other side's work.

7.4 Running a contract #

A run goes through seven steps, in order. Only step 4 has an effect.

  1. Permission and trigger. The caller holds run for the contract on the record (chapter 14.6; for an arrival, chapter 9.2a, on the record it is within, else anywhere by an admin or control edge, or as an admitted member taking part, chapter 14.4), the contract's audience admits them, who holds for the caller when the contract has one, and when holds (or a caller asked).
  2. Needs. The record satisfies needs as a class: every ! line has a value, and every value fits its narrowing. If not, the run stops before acting, naming what is missing.
  3. Map out. writes builds the payload.
  4. Act. The host performs the act: calls the system, prints the label, shows the form.
  5. Map back. reads turns the answer into values, each with a source: origin from the contract, via the log entry for this run. A contract MAY abstain for a field, or for the whole answer: it says it doesn't know. An abstention is recorded on the intake record and never becomes a value; "a wrong value is worse than none".
  6. Rules. What came in runs through intake (chapter 9) and the rules (chapter 6).
  7. Record. The run is logged with the versions of the contract, the package and the rules it used.

7.5 Runs #

Every run has a run ID (R-118), and the log entry for its writes names it (chapter 8).

  • Idempotency. A run's key is the contract, the record (or the ref when the run has no record yet, chapter 9.2a) and the hash of its mapped-out payload; for a scheduled run, the tick takes the payload's place (7.11), and for a stream batch, the view and its seq range (chapter 12.6.2). A host MUST NOT act twice for the same key unless the earlier run failed before acting.
  • Many records, one record at a time. A run over several records (an import, a batch listing) is one entry and one idempotency key per record (chapter 8.5): each item is acted or failed on its own, a refusal refuses that item, and a retry acts only on the items that failed. There is no partial success of one item.
  • Two entries around the act. A send is logged started before the host acts (its idempotency key, the hash of its mapped-out payload, and the versions it uses) and acted or failed after, so a host that restarts between the two finds the run and never acts twice. The payload itself is kept beside the log, as intake bytes are (chapter 9.1), never in it: a run write holds its hash, so a payload holding a person's data can be erased (chapter 8.5.2).
  • Retry. retry: 3 every 10m retries a failed act up to three times, ten minutes apart, from the kept payload, never re-mapping it. With no retry, a failed act is not retried. A run still failed after its last attempt stays failed and opens a review item run:<contract> (chapter 9.5), grouped by contract across records; a person's decision is a new run or nothing.
  • Late answers. An answer that arrives after its run closed names the run (the run ID travels with the act) and is taken as intake against it, folding like any other with that run as its via. An answer that names no run the store knows lands nowhere: it opens a review item and is never guessed onto a record.
  • Undo. undo: ebayEndListing names the contract that reverses this one; a run of it names the run it reverses as cause. The engine never assumes a contract is reversible.
  • Suggestions. With lands: suggestion, values that arrive don't change the record: they are kept as suggestions on it, for a person to accept. Customer corrections land this way.

7.6 Authorization and authentication #

Authorization is inside CRUBAL. Whether a caller may run a contract, edit a value or see one is decided by the caller's edges and the package's permission menu (chapter 14): that is the one spelling of permission. who is a further condition (chapter 6.3) over the caller, which is a record: a person, an organization or a system. Its values are read with the caller prefix (caller.role is staff), and caller wears Staff tests its classes. who narrows what the menu allows and never widens it. A who over the caller's values or classes reads the caller's record in this owner's log; a caller hosted elsewhere (chapter 14.8) has none here, so for them only caller may … (what their edges hold, chapter 14.6) can hold, and audience is the usual gate. What a caller sees is decided by show rules (chapter 12) and the view's partition (chapter 12.6).

Authentication is the host's job. Sign-in, OAuth flows and API keys belong to whatever runs the contracts. A contract only declares what the other side requires:

  • auth: the scheme, such as oauth2, apikey, basic or none;
  • scope: the scopes or permissions it asks for;
  • credential: a name the host resolves to its secret (ebay-seller).

No secret ever appears in CRUBAL text or its JSON. A package MUST be publishable as it is.

7.7 Forms #

A form is a contract with a person. Its screen assembles itself:

  • the fields are needs, in its lines' order and sections (chapter 5.3.3; a needs block MAY hold section: lines), each drawn with its facet's own control (a choice as bubbles, a date at its precision);
  • writes shows current values, reads takes the person's answers; a form with no reads of its own takes each answer by its facet's name, since its fields are its needs;
  • who is the person, and origin says what the person is doing: declared when they state it, observed when they read it off a real instance in front of them.

7.8 Offer and take #

Sharing between owners is two contracts, one on each side. Only the overlap flows.

An offer travels as a border wrapper (chapter 3.8): with the records, their sources and the signed entries they came from, it names the package version they were written under. The receiver applies the version rule (chapter 10.1): the same version is taken; an older one is restamped through the recipes between; a newer one is held, as a review item, until the receiver upgrades.

contract: catalogOffer
  kind: offer
  for: Movie
  gives: title, format, releaseDate, runtime, cast
  to: public
  mode: gathered

contract: catalogTake
  kind: take
  from: distributor.example
  reads:
    title -> title
    runtime -> runtime
  mode: gathered
  lands: value
  • to (offer): public, followers, named parties, link, or private. link shares with anyone holding an unguessable token: unpublishing revokes it without deleting anything, and republishing keeps the same token, so links already handed out keep working. Visibility per record, facet or value is decided by show rules.
  • from (take): the presenter, by domain.
  • mode is defined by three properties. The four named modes are presets, and a package MAY name other combinations:
Mode copy updates control
dump yes no consumer
observed no (read through) yes presenter
gathered yes yes, as intake consumer; their edits win
controlled yes yes the presenter's terms
  • terms (offer, controlled): reshare: no, expires: 2027-01-01, revocable: yes.
  • Only observed is enforced by the engine (a read-through is never stored). The others are agreements the engine honours, backed by licence and law.
  • A presenter's update never overrides a value a person entered on the consumer's side, unless the consumer took controlled terms.
  • Parties are identified by domain, with their keys published in the domain's well-known file, https://<domain>/.well-known/crubal.json (chapter 11.7). A request one owner's host makes to another's proves its party by a signature over the request with that key (an HTTP message signature, RFC 9421, over the method, the target, the body's digest and the date).

7.9 The JSON form #

{
  "contract": "ebayListing",
  "kind": "send",
  "for": "OpticalDisc",
  "needs": [
    { "facet": "title", "required": true, "length": "80" },
    { "facet": "format", "required": true, "values": ["DVD", "Blu-ray", "4K"] },
    { "facet": "price", "required": true, "level": "unit" }
  ],
  "writes": [
    { "from": "title", "to": "Title" },
    { "from": "format", "to": "aspects.Format", "values": [ { "from": "4K", "to": "4K UHD Blu-ray" } ] },
    { "from": "unit::price", "to": "price.value" }
  ],
  "reads": [ { "from": "itemId", "to": "ebay:itemId" } ],
  "gives": ["ebay:itemId"],
  "when": "unit::offerStatus is listed",
  "who": "caller wears Staff",
  "auth": "oauth2",
  "credential": "ebay-seller",
  "retry": { "times": "3", "every": "10m" },
  "version": "2026-10-01"
}

needs uses chapter 5.9's class line objects. A literal map side is {"literal": value}.

7.10 Well-formed contracts #

A package MUST be refused at publish when a contract breaks these:

  1. Every part is one its kind allows (7.2).
  2. needs is a well-formed class (chapter 5.10).
  3. Every our-side address names a facet the package defines (or an unknown-key prefix in reads).
  4. Every our-side value in a value map is a real value of its facet.
  5. credential is a name, never a value; nothing in the contract is a secret.
  6. when is asked, entry, a schedule (7.11) or a condition that parses (chapter 6.3), and who is a condition that parses.
  7. undo names a contract of the same package.
  8. stream names views of the package and comes with to and without writes or needs; when: entry comes only with stream; missed comes only with a schedule.
  9. relation is a relation the package declares (chapter 14.6).

7.11 Schedules #

A contract's when MAY be a schedule:

schedule = "every " ( N " minutes" / N " hours" / "minute" / "hour"
                    / "day" [ " at " hh:mm ]
                    / weekday [ " at " hh:mm ]
                    / "month on " D [ " at " hh:mm ] )  [ " [zone: " iana "]" ]

every day at 03:00, every hour, every 15 minutes, every monday at 09:00, every month on 1 at 00:00 [zone: America/Chicago]. A time of day is UTC unless the zone says otherwise; a monthly day is 1 to 28, so every month has it.

  • Ticks are computed. The ticks of a schedule follow from the schedule and a moment alone, so every store agrees on them. The host offers its clock as now (chapter 6.3), and every tick after the last one run and at or before now is due.
  • A schedule begins when its contract is published (chapter 11.5): nothing before that is due, so a contract published in October never owes a year of nightly runs.
  • A tick runs once. A run's idempotency key is the contract, the tick and the record when there is one (7.5), so a host that restarts never runs a tick twice; the run's entry carries the tick as its cause.
  • Missed ticks are the contract's choice, missed: once (the default: one run, for the latest tick due), all (one run per tick, in order) or skip (none). What was skipped is reported, never run.
  • What runs. A contract with for runs over every record it selects, one entry each (chapter 8.5). A stream contract pushes its views' entries (chapter 12.6.2). Any other runs once, with no record.
  • Rules never schedule anything: a schedule is a trigger, and only a contract has one (chapter 6).

Edit on GitHub · Markdown