m2-market/specs/S1-ledger-economy.md
m2 (AI Agent) 8a8db9f2cc specs: m2-market spec discovery — 6 subsystem specs + synthesis index
herdr workforce output (6 codex angle-specialists -> claude synthesis):
S1 ledger/economy, S2 catalog/registry, S3 storefront (cargstore->M2 Store),
S4 Solution Scout (standalone watcher recommended), S5 packaging + 3 seed
Solutions (mm-pdf 25cr, agent-scaffold 35cr, competitor-scan 60cr),
S6 proposal engine + evidence loop.

SPEC-INDEX is the tie-break: 23-row cross-cutting contract table (16 mismatches
resolved, e.g. /v1 ledger paths win, m2store://listing/<id> deep link, one
m2.market.telemetry.v1 envelope, op_<slug> operator ids, tenant ids from m2-gpt),
8 deduped clarifications with defaults, 4-phase build order where the paid-install
wedge does NOT block on fedlearn (local ApplyAdapter until rails land), risk register.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 02:43:50 +02:00

759 lines
26 KiB
Markdown

# S1 — m2-ledger SPEC: Ledger & Economy
## 1. Scope & Non-Goals
### MVP scope
`m2-ledger` is a standalone Coolify app on the shared `coolify` Docker network with Docker DNS alias `m2-ledger` and private base URL `http://m2-ledger:8000`. It is the append-only credit ledger for the M2 Marketplace wedge:
- Whole-number M2 credits (`m2cr`) only.
- SQLite as the authoritative store for MVP.
- Append-only transaction log; balances are derived from transactions.
- Account creation and starter grants for operators, sellers, platform, resource owners, and system accounts.
- Fixed-price install settlement: buyer debit, seller credit net of platform cut, platform cut, license grant, idempotent install ref.
- Job-based pricing settlement for service-style Solutions: escrow, milestone release, acceptance, dispute/manual cancel, and operator earnings through the same ledger.
- Route/resource settlement hooks so m2-gpt token/resource spend can be represented as ledger `route`/`job` events without duplicating m2-gpt metering.
- Federation with m2-gpt identity: every service call made with a gateway-issued tenant/agent key or service key resolves to a server-side `(tenant_id, agent_id, operator_id)` before ledger mutation.
- Daily snapshot-to-Forgejo audit: balance and transaction-hash snapshots committed to Forgejo org `m2`.
- Admin-only starter grant default: `100` credits.
- Platform cut default: `10%`.
### Later scope
- Public fiat purchase and cash payout automation.
- On-chain/crypto settlement, public token, custody, exchange, tax, and fraud workflows.
- Multi-currency balances. The schema reserves `currency`, but MVP accepts only `m2cr`.
- Negative balances, credit lines, chargebacks beyond append-only compensating entries.
- Trustless escrow. MVP escrow is an internal account state machine.
- Replacing m2-gpt billing. MVP federates with it; later it may move under an extended m2-gpt billing plane.
### Non-goals
- Catalog truth, listing review, and install package format. S2/S5 own those. S1 consumes `listing_id`, `solution_id`, version, price, seller, and job terms.
- Storefront UX. S3 owns cargstore/M2 Store.
- Scout/Hermes proposal ranking. S4/S6 own discovery/proposals; S1 supplies quote, authorization, and settlement APIs.
- Secrets management implementation. S1 requires runtime-injected keys and documents env names only.
## 2. User Stories + Acceptance Criteria
### Install purchase
Given an operator has a positive M2 credit balance and a published fixed-price listing, when M2 Store or `m2-market install` calls `POST /v1/settlements/install`, then the ledger atomically records buyer debit, seller net credit, platform cut, and a license grant.
Acceptance:
- Balance is sufficient before any rows are inserted.
- Repeated calls with the same `idempotency_key` return the existing settlement.
- No grant exists without a paid transaction group.
- Platform cut defaults to 10% unless listing revenue split overrides it.
### Insufficient funds
Given a buyer balance is below the required price, when install settlement is requested, then the API returns `402 insufficient_funds` and writes no transactions, grants, or ledger events.
### Starter grant
Given an active operator account and an admin/service key with `ledger:grant`, when `POST /v1/grants/starter` is called without amount, then the account receives exactly 100 credits from `acct_mint`.
### Job-based Solution
Given a buyer accepts an operator-delivered job quote, when `POST /v1/jobs` is called, then funds move from buyer to `acct_escrow`, a job record is opened, and the assigned operator can claim milestones only through explicit acceptance or admin/manual release rules.
Acceptance:
- `quoted -> funded -> in_progress -> submitted -> accepted -> paid` is the happy path.
- Milestone payments release from escrow to seller and platform via append-only rows.
- Cancel before work returns escrow to buyer through compensating rows.
- Dispute freezes remaining escrow until an admin resolution event.
### Route/resource settlement
Given m2-gpt records metered model spend in its `spend_log` and rollups, when an authorized route-settlement worker posts a route usage batch, then m2-ledger records a credit-denominated `route` transaction group tied to the m2-gpt source IDs, without modifying m2-gpt records.
### Audit snapshot
Given the daily cron runs, when `/v1/admin/snapshots` executes, then it writes a deterministic JSON snapshot containing balances, tx high-water mark, previous snapshot hash, and current tx chain hash, and commits it to Forgejo.
## 3. Interfaces & Data Contracts
### Runtime deployment contract
- Coolify app: `m2-ledger`.
- Network: `coolify`.
- Internal URL: `http://m2-ledger:8000`.
- Public route: none for MVP unless an admin UI needs it; prefer internal network calls.
- Health: `GET /health -> {"status":"healthy","service":"m2-ledger","db":"ok"}`.
- Required env, injected at runtime:
- `M2_LEDGER_DB=/data/m2-ledger.sqlite3`
- `M2_LEDGER_API_KEYS_FILE=/run/secrets/m2-ledger-keys.json`
- `M2_GPT_BASE_URL=https://gpt.machinemachine.ai`
- `M2_GPT_ADMIN_TOKEN` or m2-gpt service key for identity introspection
- `FORGEJO_URL=https://git.machinemachine.ai`
- `FORGEJO_ORG=m2`
- `FORGEJO_AUDIT_REPO=market-audit`
- `FORGEJO_TOKEN`
- `PLATFORM_CUT_BPS=1000`
- `STARTER_GRANT_CREDITS=100`
No secrets are committed to repos or images.
### Auth and identity
MVP supports two accepted caller modes:
1. `Authorization: Bearer sk-m2-...` for gateway-issued tenant/agent keys. m2-ledger introspects through m2-gpt or validates a signed identity assertion issued by m2-gpt.
2. `X-API-Key: ...` for service/admin keys stored only in runtime secrets.
Resolved identity object:
```json
{
"tenant_id": "m2",
"agent_id": "chris-m2o",
"operator_id": "op_chris",
"principal_ref": "agent:chris-m2o",
"scopes": ["ledger:read", "ledger:install"],
"auth_source": "m2-gpt"
}
```
Rules:
- Request bodies may include `operator_id` only for admin/service operations. Buyer identity for agent/user calls is server-derived from auth.
- Cross-tenant reads return `404`, not existence-revealing `403`, unless caller has `ledger:admin`.
- Service keys are scoped: `ledger:install`, `ledger:job`, `ledger:route`, `ledger:grant`, `ledger:snapshot`, `ledger:admin`.
- All mutations require `Idempotency-Key` header or `idempotency_key` body field; both present must match.
### SQLite tables
#### `accounts`
```sql
CREATE TABLE accounts (
account_id TEXT PRIMARY KEY,
account_type TEXT NOT NULL CHECK (account_type IN
('operator','tenant','platform','mint','escrow','resource','service')),
operator_id TEXT,
tenant_id TEXT,
display_name TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('active','suspended','closed')),
metadata_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL
);
```
System accounts:
- `acct_mint`: source for starter grants and admin corrections.
- `acct_platform`: platform cut receiver.
- `acct_escrow`: escrow holder for funded jobs.
#### `ledger_tx`
```sql
CREATE TABLE ledger_tx (
tx_id TEXT PRIMARY KEY,
tx_group_id TEXT NOT NULL,
seq INTEGER NOT NULL,
ts TEXT NOT NULL,
from_account_id TEXT NOT NULL REFERENCES accounts(account_id),
to_account_id TEXT NOT NULL REFERENCES accounts(account_id),
amount INTEGER NOT NULL CHECK (amount > 0),
currency TEXT NOT NULL CHECK (currency = 'm2cr'),
tx_type TEXT NOT NULL CHECK (tx_type IN
('install','payout','grant','route','earn','job','refund','adjustment')),
ref_type TEXT NOT NULL,
ref_id TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
actor_tenant_id TEXT,
actor_agent_id TEXT,
actor_operator_id TEXT,
metadata_json TEXT NOT NULL DEFAULT '{}',
prev_hash TEXT NOT NULL,
tx_hash TEXT NOT NULL,
UNIQUE(tx_group_id, seq),
UNIQUE(idempotency_key, tx_type)
);
```
Append-only invariant: no `UPDATE` or `DELETE` application paths. Corrections use new rows with `refund` or `adjustment`.
Balance formula:
```sql
SUM(CASE WHEN to_account_id = :account THEN amount ELSE 0 END)
- SUM(CASE WHEN from_account_id = :account THEN amount ELSE 0 END)
```
Non-system accounts may not go negative. `acct_mint` can issue grants. `acct_escrow` may only release against funded job groups.
#### `license_grants`
```sql
CREATE TABLE license_grants (
grant_id TEXT PRIMARY KEY,
operator_id TEXT NOT NULL,
account_id TEXT NOT NULL REFERENCES accounts(account_id),
tenant_id TEXT NOT NULL,
listing_id TEXT NOT NULL,
solution_id TEXT NOT NULL,
solution_version TEXT NOT NULL,
major_version INTEGER NOT NULL,
tx_group_id TEXT NOT NULL,
install_ref TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('active','revoked','refunded')),
created_at TEXT NOT NULL,
UNIQUE(operator_id, listing_id, major_version)
);
```
#### `jobs`
```sql
CREATE TABLE jobs (
job_id TEXT PRIMARY KEY,
listing_id TEXT,
solution_id TEXT,
buyer_account_id TEXT NOT NULL REFERENCES accounts(account_id),
seller_account_id TEXT NOT NULL REFERENCES accounts(account_id),
tenant_id TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN
('quoted','funded','in_progress','submitted','accepted','paid','cancelled','disputed')),
total_amount INTEGER NOT NULL CHECK (total_amount > 0),
currency TEXT NOT NULL CHECK (currency = 'm2cr'),
platform_cut_bps INTEGER NOT NULL DEFAULT 1000,
terms_json TEXT NOT NULL,
escrow_tx_group_id TEXT,
accepted_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
```
#### `job_milestones`
```sql
CREATE TABLE job_milestones (
milestone_id TEXT PRIMARY KEY,
job_id TEXT NOT NULL REFERENCES jobs(job_id),
idx INTEGER NOT NULL,
title TEXT NOT NULL,
amount INTEGER NOT NULL CHECK (amount > 0),
acceptance_mode TEXT NOT NULL CHECK (acceptance_mode IN ('buyer_accept','auto_after_timeout','admin_release')),
due_at TEXT,
status TEXT NOT NULL CHECK (status IN
('pending','funded','submitted','accepted','released','cancelled','disputed')),
release_tx_group_id TEXT,
evidence_uri TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(job_id, idx)
);
```
#### `snapshots`
```sql
CREATE TABLE snapshots (
snapshot_id TEXT PRIMARY KEY,
snapshot_date TEXT NOT NULL UNIQUE,
tx_high_water_seq INTEGER NOT NULL,
tx_chain_hash TEXT NOT NULL,
prev_snapshot_hash TEXT,
snapshot_hash TEXT NOT NULL,
forgejo_commit TEXT,
forgejo_path TEXT NOT NULL,
created_at TEXT NOT NULL
);
```
### HTTP API
All endpoints are under `/v1` except health.
#### Accounts and balances
`GET /v1/accounts/me`
Response:
```json
{
"operator_id": "op_chris",
"tenant_id": "m2",
"account_id": "acct_op_chris",
"status": "active"
}
```
`GET /v1/balances/{account_or_operator_id}`
Requires same operator/tenant or `ledger:admin`.
Response:
```json
{
"account_id": "acct_op_chris",
"operator_id": "op_chris",
"balance": 140,
"currency": "m2cr",
"as_of_tx_seq": 1234,
"as_of": "2026-07-02T00:00:00Z"
}
```
`GET /v1/transactions?account_id=&operator_id=&tx_type=&ref_type=&ref_id=&since=&limit=&cursor=`
Returns paginated append-only entries visible to caller.
#### Starter grants
`POST /v1/grants/starter`
Requires `ledger:grant`.
Request:
```json
{
"operator_id": "op_gunnar",
"tenant_id": "m2",
"amount": 100,
"reason": "starter_grant_2026w27",
"idempotency_key": "starter:op_gunnar:2026w27"
}
```
Response:
```json
{
"tx_group_id": "txg_01j...",
"tx_ids": ["tx_01j..."],
"balance": 100
}
```
#### Fixed install settlement
`POST /v1/settlements/install`
Requires `ledger:install`.
Request:
```json
{
"buyer_operator_id": "op_chris",
"seller_operator_id": "op_sdjs",
"tenant_id": "m2",
"listing_id": "lst_competitor-scan",
"solution_id": "sol_competitor-scan",
"solution_version": "1.0.0",
"install_ref": "lst_competitor-scan-v1.0.0",
"amount": 40,
"currency": "m2cr",
"platform_cut_bps": 1000,
"idempotency_key": "install:op_chris:lst_competitor-scan:1.0.0"
}
```
Settlement math:
- `platform_cut = floor(amount * platform_cut_bps / 10000)`, minimum `1` if `platform_cut_bps > 0` and `amount > 1`.
- `seller_net = amount - platform_cut`.
- Rows:
- buyer -> seller, `seller_net`, `install`
- buyer -> platform, `platform_cut`, `install`
- License grant created in the same SQLite transaction.
Response:
```json
{
"status": "settled",
"tx_group_id": "txg_01j...",
"tx_ids": ["tx_01j_a", "tx_01j_b"],
"grant": {
"grant_id": "gr_01j...",
"operator_id": "op_chris",
"listing_id": "lst_competitor-scan",
"solution_id": "sol_competitor-scan",
"solution_version": "1.0.0",
"major_version": 1,
"status": "active"
}
}
```
Errors:
- `402 insufficient_funds`
- `409 duplicate_idempotency_key`
- `409 license_already_granted`
- `422 invalid_amount_or_split`
`POST /v1/settlements/install/{tx_group_id}/refund`
Requires `ledger:admin` or service compensation scope. Creates compensating `refund` rows and marks grant `refunded`; does not delete original install rows.
#### License check
`GET /v1/licenses/{operator_id}/{listing_id}?major_version=1`
Used by `m2-core-sync`/install adapter before apply and by M2 Store for installed state.
Response:
```json
{
"grant": {
"grant_id": "gr_01j...",
"status": "active",
"solution_version": "1.0.0",
"major_version": 1
}
}
```
#### Job quotes and settlement
`POST /v1/jobs`
Requires `ledger:job`.
Request:
```json
{
"job_id": "job_01j...",
"buyer_operator_id": "op_chris",
"seller_operator_id": "op_gunnar",
"tenant_id": "m2",
"listing_id": "lst_agent-scaffold-assisted",
"solution_id": "sol_agent-scaffold",
"total_amount": 120,
"currency": "m2cr",
"platform_cut_bps": 1000,
"terms": {
"summary": "Create and install tailored workspace scaffold",
"acceptance": "buyer_accept",
"auto_accept_after_hours": 72
},
"milestones": [
{"title": "Scaffold delivered", "amount": 80, "acceptance_mode": "buyer_accept"},
{"title": "Install verified", "amount": 40, "acceptance_mode": "buyer_accept"}
],
"idempotency_key": "job:create:job_01j..."
}
```
Behavior:
- Verifies buyer balance >= total.
- Inserts buyer -> `acct_escrow` row with `tx_type=job`, `ref_type=job`.
- Creates `jobs.status=funded`.
- Creates funded milestone rows.
`POST /v1/jobs/{job_id}/start`
Seller/operator marks work `in_progress`. No funds move.
`POST /v1/jobs/{job_id}/milestones/{milestone_id}/submit`
Seller submits evidence.
Request:
```json
{"evidence_uri":"forgejo://m2/jobs/job_01j/evidence/m1.md"}
```
`POST /v1/jobs/{job_id}/milestones/{milestone_id}/accept`
Buyer or admin accepts. Releases milestone amount:
- escrow -> seller, `amount - platform_cut`
- escrow -> platform, `platform_cut`
When all milestones released, job becomes `paid`. If the job produced an installable Solution, the catalog/install subsystem may call fixed install grant separately or include a zero-price license grant only if job terms say delivery includes install rights.
`POST /v1/jobs/{job_id}/cancel`
Allowed before any milestone release by buyer/admin, or by admin after dispute. Refunds unreleased escrow to buyer and marks pending milestones cancelled.
`POST /v1/jobs/{job_id}/dispute`
Sets `status=disputed`; no movement until admin release/refund.
#### Route/resource settlement
`POST /v1/settlements/route`
Requires `ledger:route`; intended caller is m2-gpt route-settlement worker or marketplace resource router.
Request:
```json
{
"tenant_id": "m2",
"buyer_operator_id": "op_chris",
"resource_account_id": "acct_resource_spark",
"amount": 3,
"currency": "m2cr",
"source": {
"system": "m2-gpt",
"spend_log_ids": ["7fd1..."],
"route_id": "spark-glm",
"usd": "0.018400",
"conversion_rate": {"m2cr_per_usd": 100}
},
"idempotency_key": "route:m2:op_chris:2026-07-02T00:15"
}
```
Rows:
- buyer -> resource owner/platform, `route`.
m2-gpt remains the meter of record for tokens/USD; m2-ledger records credit settlement and source IDs only.
#### Snapshots and audit
`POST /v1/admin/snapshots`
Requires `ledger:snapshot`. Also run daily by cron around 00:10 UTC.
Response:
```json
{
"snapshot_id": "snap_2026-07-02",
"path": "audit/ledger/2026/07/2026-07-02.json",
"commit": "abc123...",
"snapshot_hash": "sha256:..."
}
```
Snapshot JSON:
```json
{
"schema_version": "m2.ledger.snapshot.v1",
"date": "2026-07-02",
"generated_at": "2026-07-02T00:10:00Z",
"tx_high_water_seq": 1234,
"tx_chain_hash": "sha256:...",
"prev_snapshot_hash": "sha256:...",
"balances": [
{"account_id":"acct_op_chris","operator_id":"op_chris","tenant_id":"m2","balance":140,"currency":"m2cr"}
]
}
```
Forgejo target: `https://git.machinemachine.ai/m2/market-audit`, path `audit/ledger/YYYY/MM/YYYY-MM-DD.json`. If S2 chooses a single `m2/market-registry` repo, use `m2/market-registry/audit/ledger/...`; S1 requires only an append-only Forgejo-backed audit path.
### Events
m2-ledger emits JSONL events to stdout for Coolify log collection and optionally `POST /memory/store` for pricing evidence summaries under `agent_id=market:ledger`.
Event envelope:
```json
{
"schema_version": "m2.ledger.event.v1",
"event_id": "evt_01j...",
"ts": "2026-07-02T00:00:00Z",
"type": "install.settled",
"tenant_id": "m2",
"operator_id": "op_chris",
"ref": {"listing_id":"lst_competitor-scan","tx_group_id":"txg_01j..."},
"amount": 40,
"currency": "m2cr"
}
```
Event types:
- `account.created`
- `grant.starter_issued`
- `install.settled`
- `install.refunded`
- `license.granted`
- `job.funded`
- `job.started`
- `job.milestone_submitted`
- `job.milestone_released`
- `job.cancelled`
- `job.disputed`
- `route.settled`
- `snapshot.committed`
Memory store payload for non-sensitive pricing evidence:
```json
{
"agent_id": "market:ledger",
"tenant_id": "m2",
"memory_type": "episodic",
"source": "api",
"content": "Install settled: lst_competitor-scan v1.0.0 for 40 m2cr; seller net 36; platform cut 4.",
"metadata": {
"event_type": "install.settled",
"listing_id": "lst_competitor-scan",
"solution_id": "sol_competitor-scan",
"amount": 40,
"currency": "m2cr",
"tx_group_id": "txg_01j..."
}
}
```
No client data, raw job artifacts, secrets, or private tenant-derived content goes into shared memory events.
### CLI verbs
`m2-ledger` admin CLI:
- `m2-ledger serve`
- `m2-ledger migrate`
- `m2-ledger account create --operator-id op_chris --tenant-id m2`
- `m2-ledger grant starter --operator-id op_chris`
- `m2-ledger balance op_chris`
- `m2-ledger tx list --operator-id op_chris --limit 20`
- `m2-ledger snapshot run`
- `m2-ledger audit verify-chain`
`m2-market` integration verbs call the HTTP API, not SQLite:
- `m2-market wallet balance`
- `m2-market wallet tx`
- `m2-market install <listing_id>`: quote/listing lookup -> ledger install settlement -> license grant -> runtime-sync apply.
- `m2-market job create|start|submit|accept|cancel|dispute`
### File paths
- Service DB volume: `/data/m2-ledger.sqlite3`.
- Service config: `/etc/m2-ledger/config.toml` generated by Coolify env or mounted secret.
- Service keys: `/run/secrets/m2-ledger-keys.json`.
- Snapshot staging: `/data/snapshots/YYYY-MM-DD.json`.
- Local desktop install state remains owned by S5 path: `~/.m2-market/state.json` and `~/.m2-core/state.json`; ledger only supplies grants and settlement refs.
## 4. Integration Contract With Other Subsystems
### m2-gpt
Touchpoints verified in live repo:
- Agent keys are minted per tenant/agent and resolve to `(tenant_id, agent_id)`.
- Existing tables include `tenants`, `agents`, `api_keys`, `tenant_budgets`, `spend_log`, and daily/weekly rollups.
- Harnesses call `https://gpt.machinemachine.ai/v1` using `Authorization: Bearer sk-m2-...`.
Contract:
- m2-ledger accepts m2-gpt-issued bearer identity for operator-facing reads and purchases.
- m2-ledger service/admin operations use runtime-injected service keys.
- Route settlement references m2-gpt `spend_log.id`, `tenant_id`, `agent_id`, `route_id`; m2-ledger never edits m2-gpt metering rows.
- Future migration path: move `accounts`, `ledger_tx`, `jobs`, and `license_grants` into an extended m2-gpt billing schema, preserving tx hashes and IDs. m2-ledger becomes a compatibility API or billing module. The append-only `ledger_tx` maps to a future `billing_ledger_entries`; `spend_log` maps to source metering; `tenant_rollups_daily.extras` can include credit summary.
### S2 Catalog / registry
- S2 provides published listing data: `listing_id`, `solution_id`, `solution_version`, `inventory_type`, `seller`, `price`, `tenant_visibility`, `install_ref`, `revenue_split`.
- S1 validates only the settlement payload and tenant visibility reference supplied by trusted Store/CLI. S1 does not own listing truth.
- Ledger events update install counts and pricing evidence through S2 indexer, not direct catalog mutation.
### S3 Storefront
- M2 Store calls:
- `GET /v1/accounts/me` and `GET /v1/balances/{account_or_operator_id}` before purchase display.
- `POST /v1/settlements/install` on install click.
- `GET /v1/licenses/{operator_id}/{listing_id}` to show installed/licensed state.
- Job endpoints for service-style checkout.
- Store must present price, seller, platform cut, and permission diff before settlement.
### S4 Scout
- Scout never settles directly from a passive proposal.
- Scout deep-links Store with `listing_id` and optional `proposal_id`.
- If install accepted, Store/CLI includes `proposal_id` in install settlement metadata so ledger events can feed conversion evidence.
### S5 Packaging / runtime-sync
- Install order:
1. Resolve listing and permissions.
2. Settle ledger install.
3. Receive license grant.
4. Apply via runtime-sync / `m2-core-sync`.
5. On apply failure, call refund/compensation endpoint if no durable install state was written.
- Apply path must check grant via `GET /v1/licenses/...` and write `grant_id` into local install state.
- Mixed fleet constraint: no image-specific ledger client dependency; all calls are HTTP and runtime-sync universal.
### S6 Proposal engine / Hermes
- Hermes proposal uses ledger quote/balance endpoints to compare build-vs-install and job options.
- Agents may spend only within operator-set budgets. For MVP, budget enforcement is split:
- m2-gpt enforces token/USD route budgets.
- m2-ledger enforces credit balances and job/install affordability.
- Proposal records include estimated credit cost but no ledger mutation until explicit approval.
### Forgejo
- Forgejo is audit system of record for daily snapshots.
- Commit author should be a service identity such as `m2-ledger-bot`.
- Snapshots are append-only by path. Corrections use a new snapshot or correction file; never rewrite historical snapshots after publication.
### memory-api
- Optional pricing evidence and event summaries are stored under `agent_id=market:ledger`.
- `tenant_id` must be explicit; shared market evidence uses `tenant_id=m2`.
- Tenant-private job details must not be written to shared memory. Use tenant-scoped memory only when owner-initiated and scrubbed.
## 5. Options Compared
### Ledger substrate
| Option | Pros | Cons | Verdict |
|---|---|---|---|
| Standalone `m2-ledger` SQLite service | Small blast radius, fast MVP, append-only model is easy to audit, deploys as Coolify app | Another service to operate; later migration needed | Recommended for MVP; locked decision |
| Extend m2-gpt billing now | Reuses tenant keys, budgets, spend admin UI | Higher regression risk in live gateway; mixes marketplace credit semantics with token metering too early | Later migration target |
| Forgejo-as-ledger only | Very auditable | Poor concurrency/idempotency; awkward balance queries; harder escrow | Reject |
### Job settlement model
| Option | Pros | Cons | Verdict |
|---|---|---|---|
| Full escrow at job acceptance | Protects seller capacity, simple affordability check, clear buyer commitment | Buyer locks full funds | Recommended |
| Milestone pay-as-you-go without escrow | Lower upfront buyer lock | Seller non-payment risk; harder abuse handling | Later for trusted parties |
| Manual invoice only | Simple | Does not close marketplace loop | Reject for MVP |
### Crypto-capable evolution
Recommended path:
1. MVP internal credits in `m2-ledger`.
2. Add exportable hash chain and deterministic snapshots from day one.
3. When m2-gpt billing grows a first-class billing schema, migrate ledger tables under m2-gpt while preserving IDs and hashes.
4. Only after legal/tax/custody decisions, add external payment rails or on-chain mirrors as settlement adapters. The internal ledger remains source of entitlement truth until explicitly replaced.
## 6. Risks / Edge Cases
- Tenant leakage: job descriptions and evidence can contain client data. Ledger stores refs and scrubbed summaries only; raw artifacts stay tenant-owned.
- Cross-tenant account probing: return `404` for inaccessible account/license/job IDs.
- Secrets in audit: snapshots include account IDs, balances, tx refs, hashes, not API keys or raw metadata.
- Duplicate purchase clicks: idempotency key required; duplicate install ref returns existing grant.
- Apply failure after settlement: use compensating refund rows; never delete original install rows.
- Platform cut rounding: define basis points and deterministic integer rounding in the API contract.
- Seller buys own listing: allow only with explicit `allow_self_purchase=true` admin/test metadata; default reject to prevent fake volume.
- Suspended account: cannot send or receive except admin refund/adjustment.
- Job abandonment: terms must define auto-accept timeout or admin resolution path; otherwise jobs can lock escrow indefinitely.
- Snapshot commit failure: snapshot remains local with `forgejo_commit=null`, service retries; API health reports degraded audit until committed.
- SQLite concurrency: use WAL mode, one writer, transaction boundaries around every settlement; if write lock timeout occurs return `503 retryable`.
- Hash-chain corruption: `m2-ledger audit verify-chain` must fail closed and require admin intervention before new mutation if local tx hash continuity breaks.
- Abuse/fake earnings: pricing evidence and ratings are catalog-owned; ledger can mark suspicious self-dealing metadata but does not curate.
## 7. [NEEDS CLARIFICATION]
1. Exact operator_id source of truth: derive from m2-gpt `agents.principal_ref`, m2o `fleet.json`, or a new marketplace operator registry? MVP can maintain an `accounts.operator_id` mapping, but the canonical owner should be chosen before implementation.
2. Forgejo audit repo name: use a dedicated `m2/market-audit` repo or S2's registry repo with `audit/ledger/...` paths.
3. Credit-to-USD conversion policy for route settlement: fixed operator-configured rate is enough technically, but the initial `m2cr_per_usd` value and who can change it need an operator decision.