Write a plugin
A plugin is how a new use case joins Situs without changing what a proof means. Write it, check it, package it, sign it, submit it by pull request.
- Three words
- The package layout
- The plugin API
- Purity and narrowing: what a bad plugin can and cannot do
- situs-plugin: new, check, package, sign
- The conformance suite, item by item
- Tiers
- Submitting
- Worked example
Three words
- A plugin is a use case, the type: façades, tool schemas, rule schemas, a small pure rules module, a connector description, wording, fixtures, a storyline, a regulatory map.
- A profile is a tenant's signed instance of a plugin's rules (
<plugin>.v1@<version>). Same plugin, many profiles. - A mandate is one agent's signed authority inside a profile: the Intent Contract.
The package layout
| file | what it holds |
|---|---|
| manifest.json | name, major, version, author, license, counterparty kind, minimum level, mock |
| facades.json | the façades: name, kind (read, fetch, protected), params schema, connector op |
| tools.json | the agent's tools in OpenAI function format, each mapped to a façade |
| schema/profile_rules.json, schema/mandate_rules.json | JSON Schemas of the rules blocks |
| rules.py | the pure rules module: binds PLUGIN |
| connector.json | how the enforcement point reaches the counterparty; declarative |
| vocabulary.json | the plugin's domain words, which core must never use |
| wording.json | every word people see: findings, approval cards, labels, reasons |
| regulatory_map.json | which rules map to which regulation (may declare "none") |
| fixtures/ | the profile fixture and an example mandate |
| scenarios/*.json | named calls with expected outcomes; one must be injected |
| storyline.yaml | the demo storyline, format situs.storyline/1 |
| mock/ | optional: a mock counterparty the demo runs against |
The plugin API
rules.py implements situs_core.plugin_api.PluginRules: validate_profile_rules, validate_mandate_rules, evaluate(mandate, profile, canonical_action, now, ledger) returning pass, deny or escalate with a reason code, ledger_keys, fingerprint (what "the same action" means for duplicate detection) and describe (who, what, how much, in what unit, for people).
Purity and narrowing: what a bad plugin can and cannot do
The rules module is pure Python under a static import allowlist: no I/O, no network, no clock, no randomness, no model, no service calls, a read-only ledger. Core checks run first; a plugin only sees what survived and can only deny or escalate it. Approval for the covered façade is required unless the signed profile sets approval.delegate_to_plugin: true, and the receipt records that delegation.
- A bad plugin can refuse too much, escalate too often, or word things badly.
- It cannot allow what core denied, mint or touch a grant, hold or read a key, reach the network, run code in the enforcement point, or change what a receipt proves.
situs-plugin: new, check, package, sign
uv run situs-plugin new <name> # a plugin that passes check, to start from
uv run situs-plugin check plugins/<name> # the conformance suite; live items need make up
uv run situs-plugin package plugins/<name> # writes plugin.lock with the package hash
uv run situs-plugin sign plugins/<name> --key author.pem # the author's signature in the manifestThe conformance suite, item by item
situs-plugin check runs these, in this order, with these titles (one shared list, situs_core/plugins/conformance_items.py). Items that need the stack skip without one.
| # | id | check | needs the stack |
|---|---|---|---|
| 1 | files_validate | every file validates against its meta-schema | |
| 2 | vocabulary_neutral | vocabulary declared and absent from core | |
| 3 | purity | rules.py passes the purity check | |
| 4 | determinism | evaluation is deterministic; a shifted now changes only time-dependent checks | |
| 5 | narrowing | whatever core denies stays denied under this plugin (the narrowing fuzz) | |
| 6 | storyline_receipts | the storyline runs on the local stack and every receipt passes every verifier check | yes |
| 7 | injected_scenario_denied | the injected scenario in scenarios/ is denied with one of the plugin's own reason codes | |
| 8 | approval_over_hash | the approval path exercises approval over the action hash through the approval API | yes |
| 9 | ack_binds_action_hash | the counterparty acknowledgment binds the action hash | yes |
| 10 | wording_covers_findings | wording covers every finding id or inherits | |
| 11 | regulatory_map_present | regulatory_map.json present (it may declare "none") | |
| 12 | package_hash_reproduces | the package hash reproduces |
Tiers
| tier | label | how |
|---|---|---|
| local | unlisted plugin | any package not in the bundle |
| listed | listed plugin | reviewed, merged, its package hash in situs_core/plugins_bundle.json |
| certified | certified plugin | listed, and passed the conformance suite under review |
The tier is a label in the console and the verifier, resolved from the verifier's own bundle. Never a check.
Submitting
Open a pull request to situs-plugins. CI runs situs-plugin check. A merged package enters the static bundle by its package hash; there is no registry service and no marketplace.
Worked example
The plugin written to test that core is general, walked through from its own files:
records (plugins/records/): a support agent looks up regulated customer records and releases them to approved third parties for an allowed purpose. Three façades: records.lookup and requester.verify (read) and records.release (protected, the covered façade). rules.py has four reason codes, fingerprints a release by record, recipient and purpose, and counts releases per day in the ledger; describe() names the recipient, the record and its sensitivity. The connector reaches a mock recipient on port 8004 that signs every delivery, echoing the action hash and the idempotency key. Its injected scenario (scenarios/injected_new_address.json) is a postscript asking to send the complete file to a new address: denied recipient_not_approved. Its example mandate routes restricted releases to a records officer at hardware assurance, so a key file is refused assurance_insufficient. Writing it found the generality bugs listed in docs/DECISIONS.md D34; the pep, the authority's policy, grants, receipts, the approval API and the verifier needed no change. make demo PLUGIN=records plays its storyline; situs-plugin check plugins/records passes every item.