Contract Evolution
Modality contracts evolve by appending commits. They do not edit old terms in place.
The useful mental model is:
POSTcommits change contract state.RULEcommits add accumulated constraints.MODELcommits replace the witness model. The commit is judged by the candidate model it posts, which must replay the accepted history and meet every accumulated rule. The old model is not consulted.
Rules are the authority. Models are witnesses that show the accumulated rules remain satisfiable and provide the transition predicates used to accept or reject the next commit.
V1: An Open Bootstrap
A minimal first contract can start with a bootstrap transition and then move to a governed steady state:
export default model {
initial q0
q0 -> q1 [+POST +MODEL]
q1 -> q1 [+POST +signed_by(/parties/alice.id)]
q1 -> q1 [+RULE +signed_by(/parties/alice.id)]
q1 -> q1 [+MODEL +signed_by(/parties/alice.id)]
}
The initial setup commit is accepted because it can take q0 -> q1 with both
+POST and +MODEL. After replay reaches q1, an unsigned POST is rejected
because the only +POST successor requires Alice's signature.
V2: Add a Rule, Then Replace the Witness
To make the post-bootstrap protection survive model replacement, append a rule commit:
export default rule {
starting_at $PARENT
formula {
always([-signed_by(/parties/alice.id) -signed_by(/parties/bob.id)] false)
}
}
This does not remove the old model. It adds a permanent constraint over future
witness models: every commit after the one that adds it must include either
Alice's or Bob's signature. The +RULE transition above is what
allows this separate rule commit; without it, the old model would reject the
rule addition before the accumulated rule set can grow.
A later MODEL commit is judged by the candidate model it posts, not by the
old one. It is accepted only if:
- the candidate model can replay the accepted history, and the commit takes one of its edges from the state that history reaches;
- the candidate model satisfies every accumulated rule.
So the rules, not the model, are what protect the contract. Anyone may post a candidate model; only the rules decide which candidates pass.
This replacement is acceptable because every steady-state successor remains signed:
export default model {
initial q0
q0 -> q1 [+POST +MODEL]
q1 -> q1 [+POST +signed_by(/parties/alice.id)]
q1 -> q1 [+POST +signed_by(/parties/bob.id)]
q1 -> q1 [+RULE +signed_by(/parties/alice.id)]
q1 -> q1 [+MODEL +signed_by(/parties/alice.id)]
}
This replacement is rejected because it exposes an unsigned steady-state successor:
export default model {
initial q0
q0 -> q1 [+POST +MODEL]
q1 -> q1 [+POST]
q1 -> q1 [+RULE +signed_by(/parties/alice.id)]
q1 -> q1 [+MODEL +signed_by(/parties/alice.id)]
}
The important point is that replacement is not mutation. It is a new commit, judged by the candidate model and by every accumulated rule.
Protected Party Changes
Party changes should be ordinary state changes guarded by the current model:
export default model {
initial active
active -> active [+POST +any_signed(/members) -modifies(/members)]
active -> active [+POST +modifies(/members) +all_signed(/members)]
active -> active [+MODEL +all_signed(/members)]
}
The first transition admits non-membership updates with one member signature.
The second transition admits membership edits only when all accepted
/members/*.id identities sign. The third transition asks the same authority
for witness replacement, but a model cannot protect itself: a replacement is
judged by the model it posts, so a candidate without that transition is judged
without it. To make replacement need every member, add a rule:
export default rule {
starting_at $PARENT
formula {
always([+MODEL -all_signed(/members)] false)
}
}
The contract evolution CLI smoke runs this pattern end to end: Alice alone can
append an ordinary note, Alice can add Bob while she is the only accepted member,
Bob can then append an ordinary note, Alice alone cannot post a replacement that keeps the all-members +MODEL transition after Bob is accepted, Alice and Bob together can replace the witness model with repeated --sign flags, and Alice alone cannot add /members/carol.id after Bob is accepted. Both one-signer rejected commits report missing +all_signed(/members).
The smoke checks both JSON and human-readable status/log output, so the visible
CLI view still shows Model state: active, the accepted evolution messages, and
the signer IDs after replacement.
Bounded Terms
Terms that should expire need explicit language support. A rule may not name a
model node such as active or expired: node names are the model author's
choice and bind no commit, so posted rules that use them are refused. State the
term with labels. The contract CLI smoke covers a small bounded term:
export default rule {
starting_at $PARENT
formula {
always([-signed_by(/parties/alice.id) -bool_true(/terms/delivery_complete.bool)] false)
}
}
Until accepted state has /terms/delivery_complete.bool set to true, every
commit needs Alice's signature; after that, anyone may post. In that smoke, the
current model keeps signed ordinary updates in the active state, moves to
expired only after previously accepted /terms/delivery_complete.bool
evidence satisfies +bool_true(/terms/delivery_complete.bool), and then allows
unsigned ordinary updates in expired, each carrying the same label. The run
proves the bounded term by appending the bounded rule,
rejecting the guarded move before completion evidence, accepting the same move
after completion evidence, and ending replay in the expired witness state.
The same smoke also appends an older rule first, that completion stays possible:
export default rule {
starting_at $PARENT
formula {
eventually(<+bool_true(/terms/delivery_complete.bool)> true)
}
}
It then rejects a later witness replacement whose every move carries
-bool_true(/terms/delivery_complete.bool), so completion can never happen.
That keeps the evolution lesson honest: bounded terms can make a commitment
expire, but unrelated older rules still accumulate and continue constraining
replacement models. eventually promises a path, not a commit: under predicate
theory v0 a replacement that simply drops the completion edge, without
forbidding the label, still passes; v2 refuses it. A parser-only until(...)
example is useful language evidence, but it is not contract
evolution evidence by itself.
The smoke also checks the human-readable bounded-term status/log view, including
Model state: expired, so the CLI surface a developer reads matches the replay
state proven by JSON.