Bridge Protocol v1
ARCHIVE
Archived research / exploratory draft. Not the current implementation contract. Read REST 1.0 and the current concept.
ARCHIVE
Archived exploratory draft. It does not define the current protocol or compatibility requirements. Implement REST 1.0 instead.
Accepted architecture · 1 October 2026, option 2
Agentfy hosts the harness; Store Deputy API exposes the single MCP; the OpenCart module exposes a protected REST/JSON API. Store Deputy governs connections, actor/shopper context, permissions, approvals and audit without building another harness. Connect → login/register → secure pairing → automatic agent provisioning in Agentfy with Store Deputy MCP → return to the module. App contains teams, agents, account, billing and per-agent chat.
This replaces the earlier same-day direct-module-MCP design. Existing code and prototypes below do not implement the new integration. Shopper sessions and the real OpenCart cart have separate scoped bindings; cookies stay in the shop. See the accepted central MCP decision for boundaries, delivery stages and remaining contract work.
Successor proposed · 2 October 2026. Gateway contract v2 is the draft REST contract that will replace this RPC protocol once agreed. Until then, the code still speaks v1.
Draft specification · 28 September 2026 · partly built, only on test shops. The OpenCart 3 bridge answers
connection.pair,connection.revoke,store.describe,catalog.searchandcatalog.get(bridge 0.4.0, 30 September 2026), and a spike on 29 September 2026 exercisedprice.apply(see what a write does). All of it has run only on two local test shops, never against a real store. Decisions marked accepted were made by the founder; everything else is proposed.
Store Deputy talks to every supported shop in one language of its own; each shop platform gets a small installed bridge that translates it. Adding an OpenCart version, a fork or another platform means writing another bridge that passes the same tests. The backend, the agent and the approval rules stay as they are.
A shop owner never sees this protocol. What they notice is its effects. A price that changed after they approved the list is skipped, not overwritten. A job interrupted by a network failure is never applied twice. Pressing Pause in their own admin panel stops the agent from touching the store, whatever happens on our side.
Accepted boundaries
| Decision | Consequence for the protocol |
|---|---|
| MCP lives in the Store Deputy backend | The agent never calls the shop. Only the backend calls the bridge, after approval checks |
| First platform: OpenCart 3.0.3.5 and later | Bridge code targets PHP 7.3 and Twig 2/3; see OpenCart 3 notes |
| Direct requests only | The backend calls the shop over HTTPS. The shop must be reachable from the internet; this is tested when the owner connects |
| Only the base price is written | One write operation. Specials, quantity discounts, option prices and stock are read or ignored, never written |
Why a bridge rather than each platform's own API: the execution contract requires three guarantees that native shop APIs do not provide together. A price must be written only if it still holds the value the owner approved. A repeated request must not apply a change twice. The write must be read back. We need our own code in the shop in any case, and one shared protocol lets the backend implement the connector once.
Agent ── MCP tools ──▶ Store Deputy backend ── approvals, audit, jobs
│
│ Bridge Protocol v1 (HTTPS, signed)
▼
Bridge in the shop ──▶ OpenCart 3.0.3.5+ databaseTransport
- One endpoint per shop, reported by the bridge at pairing.
POSTonly,Content-Type: application/json, UTF-8, request body at most 1 MB. - HTTPS is mandatory. The bridge refuses plain HTTP unless a development flag is set in its configuration.
- RPC style, not REST. The operation name travels in the body. A single URL is easy to host on every platform (an OpenCart route, a WordPress REST route) and the whole body is covered by one signature.
- Status 200 means the bridge answered, whether with a result or an error. Any other status, or a 200 without a valid response signature, means something between us and the bridge replied: a firewall, a CDN challenge, a maintenance page. The backend reports that as “shop unreachable”, never as a shop error. The signature decides, not the status: on the test shops an OpenCart fatal error is also served with status 200.
Request
{
"protocol": "1.0",
"id": "01J8Z6Q7T3M2K4X9V0B1C2D3E4",
"op": "catalog.get",
"params": { "product_ids": [42, 43] }
}Response
{
"protocol": "1.0",
"id": "01J8Z6Q7T3M2K4X9V0B1C2D3E4",
"ok": true,
"result": { "products": [] }
}On failure ok is false and result is replaced by "error": { "code": "…", "message": "…", "retryable": false, "details": {} }. id is a ULID chosen by the backend. It is unique per request and echoed back.
Authentication
Everyday requests: HMAC in both directions
Each connection has a connection_id and a 32-byte secret, known to the backend and the bridge. Every request and every response carries:
| Header | Value |
|---|---|
X-SD-Connection | connection_id |
X-SD-Timestamp | Unix seconds |
X-SD-Nonce | Random value, 16+ bytes, base64url |
X-SD-Signature | v1= + hex HMAC-SHA256 of v1\n{timestamp}\n{nonce}\n{sha256_hex(body)} |
The bridge rejects requests more than 300 seconds from its clock or with a nonce seen in the last 10 minutes. Signatures are compared in constant time. The backend verifies the response signature, which is how it tells the bridge from a proxy page.
The Authorization header is deliberately unused: common CGI/FastCGI configurations do not pass it to PHP.
Pairing: only our backend can connect a shop
Pairing must not be possible for someone who merely sees a URL. The bridge holds a set of Store Deputy's public signing keys, each with a key ID, and accepts a pairing request only if it is signed by one of them. The initial set ships with the module; it can be updated without a module update (see key management).
- The owner presses Connect in the module. The bridge creates a one-time
pairing_code(valid 10 minutes, bound to that admin user) and redirects to the Store Deputy account site with the code, the bridge endpoint and the return URL. - After the owner signs in, the backend calls
connection.pairon the bridge, signed with the backend private key (ECDSA P-256 via OpenSSL). It sends the code, a newconnection_idand secret. - The bridge checks the signature and the code, stores the credentials, burns the code and responds with the
store.describemanifest, HMAC-signed with the new secret. This call also proves that the backend can reach the shop. If it fails, the owner learns at setup, not during the first price job. - The backend returns the owner to the module. The return URL must be on the host that started pairing.
The secret never appears in a URL, an HTML page or a log.
As built in iteration 3 (29 September 2026), on the local test shops. The pairing request carries X-SD-Key-Id, X-SD-Timestamp, X-SD-Nonce and X-SD-Pairing-Signature: v1=<base64url DER ECDSA-SHA256> over the same canonical string as the HMAC. Its params are pairing_code, connection_id, secret and display (team name and the email of the person connecting, shown on the module page). The bridge signs its answer with the proposed secret, even when it refuses, so Store Deputy can always tell the bridge answered. A code is stored only as a SHA-256 hash and is consumed by a conditional update, so two requests with the same code cannot both succeed. The return address includes OpenCart's admin user_token; the site sends no referrer, and Store Deputy does not store it.
Key management
Accepted: keys must be replaceable through an interface, not only by a module update. The mechanism below is proposed and not built yet: in this version the module trusts one backend key, written into it when the module is packaged (decision of 29 September 2026; rotation is a separate iteration).
There are two kinds of key, and each can be replaced on its own.
| Key | Used for | How it is replaced |
|---|---|---|
| Backend pairing keys (public, in the bridge) | Verifying connection.pair | trust.update from the backend, or manually in the module |
| Connection secret (shared) | Signing everyday requests and responses | connection.rotate_secret from the backend, or Reconnect in the module |
Two tiers of backend keys. A root key is kept offline and signs nothing except key sets. Pairing keys are used day to day by the backend. The bridge accepts a new set of pairing keys only if the set is signed by a root key it already trusts. A leaked pairing key can therefore be retired, and it cannot be used to install an attacker's keys. The root keys themselves change only when a set is signed by the current root, or by a module update.
trust.update params: keyset (key IDs, public keys, not_before, not_after), keyset_version (must be higher than the stored version, so an old set cannot be replayed) and root_signature. The bridge keeps the previous set until the new one's not_before, so there is no moment when pairing fails.
connection.rotate_secret is HMAC-signed with the current secret and carries the new one. Both secrets are accepted for 10 minutes, so requests already in flight still succeed. After that only the new one is valid.
In the module, the Connection tab shows the connection ID, the fingerprints and IDs of trusted keys, the key-set version and when each key was last changed. It offers three actions:
- Reconnect runs pairing again and issues a new connection secret.
- Import key set accepts a signed key set file published by Store Deputy, for example when the backend cannot reach the shop. The signature check is the same as for
trust.update. - Reset to bundled keys restores the keys that came with the installed module version and disconnects.
Every key change is written to the operation journal with the actor: the backend, or the admin user in OpenCart.
Disconnect and pause
- Disconnect (in the module, or
connection.revokefrom the backend) deletes the secret. Every later request fails withnot_paired. - Pause is the owner's own switch in the OpenCart admin. While paused, the bridge answers every operation except
store.describeandconnection.revokewithpaused: disconnecting only ever reduces access, so it keeps working. It does not depend on the backend behaving correctly.
Operations
| Operation | Capability | Kind |
|---|---|---|
connection.pair | — | Setup, asymmetric signature |
connection.revoke | — | Setup |
connection.rotate_secret | — | Setup |
trust.update | — | Setup, root-key signature |
store.describe | — | Read |
catalog.list | catalog.read | Read |
catalog.search | catalog.read | Read |
catalog.get | catalog.read | Read |
price.apply | price.base.write | Write |
operation.get | price.base.write | Read |
Order lookup, stock writes, specials and options are not part of v1 (order lookup deferred by decision, 28 September 2026). Any of them would arrive as a new capability in a minor version.
store.describe
Returns the manifest the backend uses to decide which agent tools are enabled for this shop. The agent is never offered a tool the shop did not declare.
{
"bridge": { "version": "1.0.0", "protocols": ["1.0"] },
"platform": {
"name": "opencart",
"reported_version": "3.0.3.8",
"detected_version": "3.0.3.9",
"fork": null,
"php": "7.4.33",
"db": "MariaDB 10.6"
},
"store": {
"stores": [{ "store_id": 0, "name": "Example Store", "url": "https://shop.example.com/" }],
"default_currency": "EUR",
"prices_entered_without_tax": true,
"display_prices_with_tax": true,
"admin_language": "en-gb",
"catalog_collation": "utf8_general_ci",
"timezone": "Europe/Kyiv"
},
"limits": { "max_ids_per_read": 500, "max_items_per_write": 50, "time_budget_ms": 20000 },
"capabilities": [
{ "name": "catalog.read", "version": 1 },
{ "name": "price.base.write", "version": 1 }
],
"warnings": [
{ "code": "product_edit_listener", "details": { "event_code": "example_feed", "trigger": "admin/model/catalog/product/editProduct/after" } }
],
"paused": false
}This example is illustrative, not output from a real shop.
detected_versioncomes from fingerprints in the code, not only theVERSIONconstant. The 3.0.3.9 release still declares3.0.3.8.warningslists things that may behave differently after our write. Examples are extensions subscribed to product-edit events and OCMOD modifications that touch the product model; see why.- More than one store is worth a warning in the review table. In OpenCart the base price is shared by all stores in an installation.
catalog.list
Pages through products in ascending product_id to build the backend's matching index. Params: after_id (default 0), limit (≤ max_ids_per_read), optional modified_since (ISO 8601). Returns products (same shape as catalog.get) and next_after_id or null.
catalog.search
Finds products by identifier. It returns every match and never chooses one. Resolving a duplicate is a decision for the owner, taken in the backend.
{
"identifiers": [
{ "field": "sku", "value": "AB-100" },
{ "field": "ean", "value": "4006381333931" }
]
}field is one of product_id, model, sku, upc, ean, jan, isbn, mpn. The result keeps the input order: [{ "input": …, "product_ids": [ … ] }]. Matching uses the database collation. On a default OpenCart installation that means case-insensitive, and trailing spaces are ignored. The manifest reports the collation so the backend can explain a surprising match.
catalog.get
Params: product_ids (≤ max_ids_per_read). Returns, per product:
| Field | Notes |
|---|---|
product_id, model, sku, upc, ean, jan, isbn, mpn | Raw values |
name | In the admin language |
status, quantity, store_ids, date_modified | Read-only context |
price | Decimal string, 4 places, e.g. "19.9900", in the default currency, without tax |
tax_class_id | The price is stored without tax; tax applies at display |
active_specials | Specials in effect now: customer group, price, dates, priority. Read-only |
has_discounts, has_option_prices | Whether other price mechanisms exist |
Amounts are always decimal strings, never JSON numbers. Floating point is not acceptable for money.
IDs with no product are listed in missing rather than failing the request.
active_specials exists so the review table can warn the owner. When a special applies, customers see the special price, not the new base price.
price.apply
The only write in v1. The backend sends it only for an approved job version; the agent cannot call it with an arbitrary price.
{
"job_id": "job_01J8Z7…",
"approval_ref": "apr_01J8Z7…",
"items": [
{
"operation_key": "opk_01J8Z7…",
"product_id": 42,
"expected": { "price": "19.9900" },
"set": { "price": "21.5000" }
}
]
}At most max_items_per_write items. For each item, in order, the bridge:
- Returns the stored outcome if
operation_keyis already finished in its journal. Nothing runs twice. - Records the key in the journal as
started. - Runs one conditional statement:
UPDATE product SET price = :set, date_modified = NOW() WHERE product_id = :id AND price = :expected. - If no row changed, reads the product. A missing product is
not_found. A different price isconflict, reported with the current value. - Reads the price back and compares it with
set. - Writes the outcome to the journal.
After the batch it clears OpenCart's product cache, as the admin does after saving a product. Otherwise cached lists such as bestsellers keep showing the old price.
Item outcomes:
| Outcome | Meaning | Backend action |
|---|---|---|
applied | Written and read back as set | Done |
conflict | Price was not expected; nothing written | Skip; a new proposal needs a new approval |
not_found | Product no longer exists | Skip and report |
rejected | Invalid item: bad decimal, negative price, expected = set | Fix the job; not retried |
not_attempted | Time budget ran out before this item | Resend with the same operation_key |
failed | Database error; nothing confirmed | Check with operation.get, then decide |
The batch is not atomic. OpenCart 3 tables use MyISAM, which has no transactions, so each item has its own outcome. The bridge stops starting new items when time_budget_ms is spent. Shared hosting often ends a PHP request after 30 seconds, and a half-finished request with no response is the worst case.
operation.get
Params: operation_keys (≤ 500). Returns the journal entry for each key, or unknown. After a timeout the backend calls this and does not resend blindly:
unknown— never reached the bridge; safe to resend with the same key.- A finished outcome — record it; do not resend.
startedwith no outcome (the request died mid-item) — resend with the same key. This is safe because the write is conditional: if it was applied, the price no longer equalsexpected. The item then comes back as a conflict, and the backend sees that the current value equalsset, which means applied, and reconciles.
What a write does and does not do
The bridge does not call OpenCart's editProduct. That function deletes and re-inserts a product's specials, discounts, options, images and descriptions from a complete form submission, and it has no condition on the old price. Using it to change one number would risk everything else on the product and lose the conflict guarantee.
Other extensions still need to learn that a price changed: marketplace feeds, search indexes, ERP synchronisation. In OpenCart 3 they find out in one of three ways, and each is handled differently.
| How the extension notices | Example | Sees our write? |
|---|---|---|
Reads the database when it runs, or looks for newer date_modified | A feed built on request or by cron; an export that pulls changed products | Yes. The write sets date_modified, and the cache is cleared |
Subscribes to the admin/model/catalog/product/editProduct/after event | A module that pushes each saved product to a marketplace | Yes on the test shops; the bridge fires the event itself, see below |
| An OCMOD or vQmod modification patches the product model or controller | Extra code inserted into editProduct | No. That code runs only inside editProduct |
Proposed: fire the after-save event after the write. After a price is applied and read back, the bridge triggers model/catalog/product/editProduct/after in OpenCart's admin context, with the same arguments an admin save would produce: the product ID and the complete product data, assembled by the admin model's own getters, the way the product form loads it. To a listener, this looks like the owner pressing Save. The event fires once per applied item, never for conflict or not_found.
Spike result, 29 September 2026. On local test shops running OpenCart 3.0.3.5 (PHP 7.3) and 3.0.5.1 (PHP 8.1, with a renamed admin directory), a test extension listening for product saves received exactly one event per applied price, with the product's descriptions, stores, specials, discounts and options. Those related records were unchanged byte for byte after the write. A conflicting write fired nothing. This has not been tried on a real shop.
Limits of this approach:
- Before-save listeners are not called. They run before a write and can alter or replace it. Our write is already committed and conditional.
- Fields added to the product form by a modification are not returned by the standard getters, so a listener may see them as empty. The bridge reports such modifications in
warnings. - Admin context is required. Listeners registered for
admin/triggers are loaded only by the admin application. The admin application sends every request without an admin session to the login page, and the catalog application cannot load the admin product model, because both classes are namedModelCatalogProduct. The bridge therefore needs its own entry point that starts the admin application with our signature check in place of the login step. If that proves unreliable on real shops, the fallback is the catalog-side route without event notification. - The entry point must also replace the router. OpenCart's standard router runs whichever admin controller
?route=names. Without the login step that would let anyone run any admin action through the bridge. The bridge's router runs only the bridge. In a control run on a test shop, restoring the standard router made the corresponding scenario fail, as it should.
The bridge lists detected event listeners and product-related modifications in warnings, so the owner knows before connecting which extensions will see changes and which will not.
Errors
| Code | Retryable | When |
|---|---|---|
signature_invalid | no | Bad or missing signature |
timestamp_skew | no | More than 300 s from the bridge clock |
replayed | no | Nonce already seen |
not_paired | no | No credentials, or revoked |
paused | no | Owner paused the agent in the admin panel |
unsupported_protocol | no | Major version not supported; details.supported lists versions |
unknown_op | no | Operation not implemented by this bridge |
capability_unavailable | no | Implemented, but not available on this shop |
invalid_params | no | Validation failed; details names the field |
limit_exceeded | no | Too many IDs, items, or too large a body |
insecure_transport | no | Plain HTTP to a release build of the module |
unknown_key | no | connection.pair signed with a key id the module does not trust |
pairing_code_invalid | no | No such connection code |
pairing_code_used | no | The code was already used |
pairing_code_expired | no | The code is older than 10 minutes |
internal | yes | Unexpected bridge failure; message never contains secrets or SQL |
Versioning
protocolismajor.minor. A minor version only adds: new operations, new optional fields, new capabilities. Both sides ignore fields they do not know. A major version may break, and a bridge may speak several.- The backend supports the current and the previous major version, so owners are not forced to update the module the day we release.
- A capability has its own version. It changes when the meaning of an operation changes on a platform, while the protocol stays the same.
How a new platform or version joins
- Implement the operations on the new platform, reusing the portable bridge core: envelope, signatures, nonce store, journal, manifest.
- Add a shop image for that version to the test matrix.
- Pass the conformance suite. Until then the version is not supported, whatever its code looks like.
Proposed conformance scenarios: reads match the database; identifier search returns all duplicates; a write applies and reads back; a concurrent admin edit produces conflict; a repeated operation_key does not write twice; a request killed mid-item reconciles through operation.get; a time budget produces not_attempted and a clean resend; wrong signature, old timestamp, reused nonce, revoked secret and paused shop are refused; a different connection's credentials are refused; a supplier file containing instructions has no effect on what the bridge does.
OpenCart 3 implementation notes
These notes come from comparing OpenCart release tags. They have not been tested on a running shop.
- Own entry point booting the admin application (worked in the spike on both test shops). OpenCart's extension installer cannot write to the shop root, so the archive ships the entry point in
system/library/storedeputy/and the module's Install step copies it to the root with the admin directory name filled in; Uninstall removes it. If the root is not writable, the module page says which file to upload by hand. The signature check replaces OpenCart's login step; see what a write does. Shops often rename theadmindirectory, so its location is recorded at installation. Fallback: a catalog-side route, which cannot fire admin events. - Own tables: an operation journal (unique
operation_key), kept indefinitely by decision, and a nonce store. Nonces are only needed for the 10-minute replay window and are removed after it. Credentials and the trusted key set go in OpenCart settings. Anyone with database access can read them, which is true of every OpenCart extension secret. - PHP 7.3 syntax floor: no arrow functions, typed properties,
matchor named arguments. 3.0.5.x requires PHP 8.1, so the code must also run cleanly on 8.x. - Twig 2 and 3: 3.0.3.5–3.0.3.8 ship Twig 2.13, 3.0.3.9 and later ship Twig 3. Admin templates use only syntax valid in both. For example, no
{% spaceless %}and nofor … if. - Collation and charset differ between old installations (utf8) and fresh 3.0.5.x ones (utf8mb4). Bridge tables follow the product table's charset.
Open questions
- Does the entry point, which worked on the test shops, also work on real shops with security extensions and OCMOD modifications installed?
- Optional IP allowlisting of our backend's egress addresses, once hosting is chosen.
- Which forks to support; decided only by real pilot shops.
Related: authority and recovery, implementation proposal, OpenCart module flow.