What singular-negative shows

You are evaluating Singular, and this repository is all you have. It claims rules: only a key's controller may update that key, a key the registry already holds cannot be taken again, a holding is released only inside a fold. This page shows where those rules are enforced — on the chain, by scripts — and how you can watch each refusal happen from the release archive, with no trust in the client that ships with the repository.

Who this is for

You are a person deciding whether to build on Singular. The ordinary client is polite to its user: several of the registry's rules it enforces itself, before your transaction ever leaves the machine. A refusal from the client proves nothing about the chain, so politeness is exactly what an evaluator must be able to switch off.

singular-negative is that switch. It is its own executable in the release archive, built from the same command-line and registry sources as the ordinary executables, with the safeguards taken out: it builds the transaction you asked for, signs with whatever key file you name, skips every local check and every local script evaluation, and submits. The node takes the transaction or refuses it, and when it refuses, the verdict names the script that refused.

Two people make it concrete. Alice has registered the key alice-1 and is its controller; Bob is anyone else. Bob types the same move to both executables.

To the ordinary client:

open-datum update --key alice-1 --payload new.json --wallet-skey bob.skey

The client reads the key's envelope, sees that its controller is not the key that would sign, and refuses: the envelope's controller is 0x… but this command signs as 0x…. Nothing is submitted. The node never sees the move.

To the negative host, the same move:

singular-negative registry update --key alice-1 --payload new.json --wallet-skey bob.skey

The host builds the update, signs it as Bob, and submits it unevaluated. The application's spending contract refuses it, the node's verdict names that script, and a fresh read shows the registry, the key's holding and the wallets exactly as they were.

Beside every forbidden move its accepting control runs too: the same command with the one forbidden difference removed, from the same registry state, signed by the same signer. A refusal only means something when the same machinery succeeds on the next line.

Who refuses what

One move, two clients. The ordinary client answers from its own local policy; the negative host sends the move to the node and the chain's scripts answer.

flowchart TD
    Person[Bob, evaluating] -->|ordinary client| Ordinary[open-datum update]
    Ordinary -->|local policy| Local[Client refusal<br/>nothing submitted]
    Local ~~~ Host
    Person -->|negative host| Host[singular-negative<br/>registry update]
    Host -->|builds and signs| Node[The node<br/>no local checks]
    Node -->|runs the scripts| Scripts[Application contract<br/>or state validator]
    Scripts -->|rule does not hold| Refused[The transaction<br/>is refused]
    Refused -->|verdict names it| Receipt[The host's receipt:<br/>refusal plus fresh reads,<br/>nothing moved]

The diagram follows one move through the two clients. The ordinary client answers from its own reading of the key's envelope — it names a controller, and this signer is not it — and submits nothing. The negative host builds the transaction, signs with whatever key file was named, submits with no local check and no local script evaluation, and the chain's scripts answer: the application's spending contract for a spend of a holding, the registry's state validator for a fold. The refusal comes back with the failing script's hash and its role in the registry the command opened — application-spending, application-at-fold or state-validator — and a fresh read confirms nothing moved. Nothing is typed in: the expected script is read from the registry's own pins, so a pass never comes from a literal someone wrote down.

The short fold of a booked termination

One sequence carries the most rules at once, over one request. Alice has registered alice-2 and is its controller. She books its termination and stops at the pending request; the termination is not done until a fold settles it. Bob, who is nobody in particular, then tries to settle it for less than owed, and finally settles it honestly.

sequenceDiagram
    participant Alice as Alice
    participant Node as the node
    participant Host as the host
    participant App as application
    Alice->>Node: terminate alice-2
    Node-->>Alice: pending, held
    Host->>Node: fold --pay-short 1
    Node->>App: run the rule
    App-->>Node: refused: pays less
    Node-->>Host: verdict names it
    Host->>Node: read: pending
    Host->>Node: fold, same request
    Node-->>Host: settled in full

The sequence is connected over one pending request. Alice, the controller of alice-2, books its termination with the ordinary command and stops at the pending request; the termination is not done until a fold settles it. Bob, who is nobody in particular, first settles it for one lovelace less than owed, through the host: the application's script at the fold refuses, the request stays pending, the deposit stays held. Bob then folds the same request honestly, through the same host and from the same state: the honest fold burns the key's token, leaves the key terminal and pays the controller exactly what the booking owes. The one difference between the two folds is the payment, and that difference alone moves the verdict from refusal to acceptance.

Every forbidden move, and what refuses it

The scenario set is thirteen pairs in nine stories: the rewrite of a protected field has five variants, one per protected field. Each pair is one forbidden command and its accepting control, run on a fresh development node from the extracted release archive. The pair list itself lives in applications/open-datum/negative/pairs.json; the model statements behind each refusal are on the ticket's data model page.

After the command-line split landed (lambdasistemi/singular#528), the ordinary surface is two executables: singular, with the registry's own commands — create, fold, reject, reclaim, inspect — and open-datum, with the application's — insert, update, terminate, fold, inspect. The host keeps one namespace, singular-negative registry …, with the shared flag group, so a host command reads the same today as it did before the split. The mirrors named below are the post-split ordinary spellings.

Forbidden command under singular-negative Refused by The ordinary command it mirrors What the ordinary command does instead The accepting control
registry update --key alice-1 --payload new.json --wallet-skey bob.skey — a stranger's update (stranger-updates-the-key) the application's spending contract open-datum update --key alice-1 --payload new.json --wallet-skey bob.skey refuses before submitting: the envelope names its controller, and this signer is not it the controller's own update of the same key
the controller's update --tamper controller (controller-tamper-controller) the application's spending contract open-datum update --key alice-1 --payload new.json --wallet-skey alice.skey rebuilds the holding with the same address, the same value, the same recorded control and only the payload changed; no ordinary flag rewrites a protected field the controller's update with no tamper flag
the controller's update --tamper deposit (controller-tamper-deposit) the application's spending contract the same open-datum update the same: the recorded deposit is protected, not an input the controller's update with no tamper flag
the controller's update --tamper token (controller-tamper-token) the application's spending contract the same open-datum update the same: the key's token stays with the holding, not an input the controller's update with no tamper flag
the controller's update --tamper address (controller-tamper-address) the application's spending contract the same open-datum update the same: the holding's address is protected, not an input the controller's update with no tamper flag
the controller's update --tamper datum (controller-tamper-datum) the application's spending contract the same open-datum update the same: the recorded control travels with the holding, not an input the controller's update with no tamper flag
registry terminate --key alice-1 --wallet-skey bob.skey — a stranger's termination booking (stranger-terminates-the-key) the application's spending contract, at the booking open-datum terminate --key alice-1 --wallet-skey bob.skey refuses before submitting: the termination booking is the controller's the controller's own termination booking
registry withdraw --key alice-1 --wallet-skey alice.skey (controller-withdraws-outside-a-fold) the application's spending contract none: no ordinary command releases a holding outside a fold books the termination (open-datum terminate) and folds it (open-datum fold); the release happens inside the fold the same release inside the honest fold of the booked termination
registry insert --key alice-1 --payload new.json --wallet-skey bob.skey, the key already active (stranger-registers-a-taken-key) the registry's state validator, at the fold open-datum insert --key alice-1 --payload new.json --wallet-skey bob.skey refuses before submitting: the key is already registered the same insertion of a fresh key the registry never held, from the same registry state
the same insertion signed by the key's owner (owner-registers-a-taken-key-again) the registry's state validator, at the fold the same open-datum insert the same refusal: ownership of the key does not reopen it the same fresh-key insertion from the same state
registry insert --key alice-1 --payload new.json --wallet-skey alice.skey, the key terminal (owner-revives-a-terminated-key) the registry's state validator the same open-datum insert refuses before submitting: the key is already retired, permanently the same fresh-key insertion from the same terminal state
the same revival signed by a stranger (stranger-revives-a-terminated-key) the registry's state validator the same open-datum insert the same refusal: a terminated key stays terminal for everyone the same fresh-key insertion from the same terminal state
registry fold --pay-short 1 --wallet-skey bob.skey over the pending termination of alice-2 (short-fold-of-a-booked-termination) the application's spending contract, at the fold open-datum fold --wallet-skey bob.skey builds the fold from the public view and pays the controller exactly what the booking owes; no ordinary flag pays less the honest fold of the same request

Read the four insertion rows together, because their timing is the subtle part: an insertion is only a request. Under the host, the booking of a taken key is accepted — a request is a request — and the refusal comes later, when the host folds it and the registry's state validator sees the key is already held. The ordinary client stops the same move much earlier, from its own reading of the registry's entry, which is exactly why the host exists: only it can show you the chain's own answer.

Three forms have no ordinary equivalent at all, and they are the host's own spellings:

  • registry withdraw spends a holding directly, with the release redeemer, outside any fold. The ordinary surface has no such command: a release exists only inside the fold of a booked termination.
  • registry update … --tamper FIELD rewrites one protected field of the holding's record — controller, deposit, token, address or datum. The ordinary update accepts no such flag and cannot express the move.
  • registry fold … --pay-short N builds the fold paying the controller that many lovelace less than owed. The ordinary fold has no flag to deviate from what is owed.

Every ordinary spelling the host accepts is handed to the shared parser unchanged, and the ordinary executables refuse these three bypass spellings as unknown commands — the release check reads the built ordinary executables to prove they contain no host module and no copy of the bypass.

What each command prints

Each host command prints one JSON receipt on standard output. For a refused move it carries: the command and its outcome class (ledger-refusal — distinct from client-refusal, encoding-failure and setup-failure, which are never counted as the chain's word), the node's own refusal text, the failed script hashes with their roles in the opened registry, the registry's application and state pins, and two reads — before and after — of the registry state root, the key's holding with its value and envelope, the wallet's outputs, the pending requests and the held deposit. A pair passes only when the after-read equals the before-read.

For an accepted command the receipt names what the chain took: the update names its transaction id, a booking names the request it created, a fold names the requests it folded. So one run shows both halves: the transaction the host really built, and the node's own verdict on it.

How to run it

From a checkout of this repository — the command assembles the release archive itself, extracts it outside the checkout, and builds the host, the ordinary executables and the development node from the archive's own flakes:

nix run --quiet .#negative-host-parts          # every scenario name, one per line of JSON
nix run --quiet .#negative-host-part -- 'stranger-updates-the-key'
nix run --quiet .#negative-host-mutants        # every mutant validator, one at a time
nix run --quiet .#negative-host-ordinary       # the ordinary executables hold no bypass

One scenario run starts a fresh node, funds Alice and Bob, runs the forbidden command and its accepting control, judges every clause from the receipts alone, and scans every receipt and output for the key bytes the run generated (keys are given by path and never printed). The thirteen scenario names are the pairs of the table above.

The hosted workflow runs every pair on every push: it discovers the scenario list from the host's own table, runs each scenario as its own job from the release archive, and aggregates them under the two stable check names Negative host pairs and Negative host mutants. Those two names are what a repository can require; the jobs beneath them may grow as pairs are added, the names stay.

A refused insertion strands a booking, and its owner reclaims it

An insertion of a taken key is refused at its fold, not at its booking, so the booking the host made stays pending on the chain, holding its deposit. The owner takes it back with the ordinary command:

singular registry reclaim --request TRANSACTION#INDEX --blueprint plutus.json \
  --state-token POLICY.NAME --koios-url URL --network-magic N \
  --wallet-skey owner.skey

The reclaim is governed by time, and the timing matters. A booking's retract window opens only after the registry's processing window has passed — that ordering is the model's own, not a client convenience — so a reclaim submitted earlier is refused by the ordinary command before signing: it reports not-phase2 and submits nothing. The scenario run creates its registry with a processing window of 120 seconds and a retract window of 600 seconds (the other scenarios use a 30-second retract window), then waits out the two minutes of processing before submitting one reclaim. This is a timed reclaim, not an immediate one. The reclaim's receipt names the command (reclaim), its outcome (success), the request and its edge (insertActive), and the read afterwards shows the booking collected and the deposit returned to its owner.

What a run does not show

A refusal shows the rule held for that transaction on that chain; the model statements and validator tests carry the general guarantee. The runs on this page are evidence about each transaction they ran, not a proof about every transaction there could be.

What is deliberately not there yet:

  • Rejection pairs. A folder turning a request away is not among the pairs; protected rejection is its own work (lambdasistemi/singular#498, lambdasistemi/singular#529).
  • Two update rules with no pair. The rule that a continuation keeps at least the protected deposit in its value, and the rule that exactly one output carries the key's token, are refused earlier by other rules in every pair here, so neither is exercised by its own pair (lambdasistemi/singular#552).
  • Each pair must be able to fail. A green refusal proves nothing unless a validator that dropped the rule would have accepted the move. For every refusing rule a mutant validator removes exactly that rule, the release archive is rebuilt from the mutated source, and the pair must flip — the node must accept what the rule refuses. Eight mutants cover all thirteen pairs.
  • The crafting exists twice. The conformance harness keeps its own copy of the crafted transactions; the host's copy is the one the release archive ships (lambdasistemi/singular#551).
  • A finished local run may leave its node behind. A part run locally can leave its development node and working directories on the machine (lambdasistemi/singular#560); hosted runners are unaffected.
Mutant The rule it removes The pair that must flip
update-controller-signature the controller must sign an update stranger-updates-the-key
update-escape the continuation stays at the holding's address controller-tamper-address
update-control the continuation keeps the holding's recorded control controller-tamper-controller, controller-tamper-datum, controller-tamper-deposit
token-continuation-leaves-the-holding the token continuation stays at the holding's address and keeps its recorded control — both rules, removed together controller-tamper-token
termination-booking-controller-checks a termination booking is the controller's — the owner and controller-signature checks, removed together stranger-terminates-the-key
release-no-fold a holding is released only inside a fold controller-withdraws-outside-a-fold
release-additive a fold pays the controller at least what is owed short-fold-of-a-booked-termination
registry-accepts-a-taken-key the registry's defence of a taken key — the labelled key-exists refusal in the fold and the insert step's own precondition (the Merkle-Patricia-Forestry library's insert requires the key to be absent, the same check the label sits behind) — both parts, removed together stranger-registers-a-taken-key, owner-registers-a-taken-key-again, owner-revives-a-terminated-key, stranger-revives-a-terminated-key

The registry row removes two things at once because one alone could never flip: taking out only the labelled refusal leaves the node refusing, since the insert step makes the same check itself. The runner builds this mutant the same way as the others, inside its own scratch copy only: the pin of the registry's state script, the registry manifest, the permanent-contract file and the known-scripts module are regenerated, and so are the application's blueprint, manifest and record, so the executables built there accept the mutated hashes. The checkout, the shipped loader and the permanent contract are not changed, and the run checks that. The control pair for this mutant is stranger-updates-the-key.

The mutant table is the honest measure of the refusal evidence: each row names the one rule a validator had to lose before the chain would take the move it refuses today. Beside every mutant a control pair that a different rule refuses must stay refused, so a patch that disabled more than its named rule could not hide.