Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/MARKET_CLEARING.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,37 @@ Operation name alone is insufficient authority: private money/market operations
Backup/restore tests include pending purchases, active entitlement grants, funded/unpaid/settled orders, delivery references, nonce consumption and Claim receipts. Restore intentionally pauses writes; bounty replay tests verify that deleting the local drill marker does not bypass containment. Old ledger-account migration remains idempotent; history is not reconstructed from mutable catalog values. Production migration, real console/SMTP/ingress and deployment acceptance remain separate, authorized work (#84).

Regression entry points: `tests/test_market_71.py`, `test_market_redemption.py`, `test_market_72.py`, `test_market_73.py`, `test_market_contracts.py`, plus existing money/admin/offers/bounty/orders/delivery, migration, hosting, dictionary, backup and conformance suites. Test totals and exact commit/CI identity belong in the PR verification record, not an unversioned completion percentage.

## Public account transparency

`money.public_balance@1` and `money.public_ledger@1` accept a stable `subject_id`.
Anonymous readers can inspect Root and currently active bank agents. Other
registered/custodial users start private and may choose publication using their
own signed `money.visibility_set@1` request. A bank's private preference is saved
but cannot hide its account while its bank role is active. Revocation immediately
reverts to that user's saved preference; archived accounts are not published.
Publication covers the full account history, including transactions before the
opt-in or bank grant. Returning an ordinary account to private hides that full
projection on subsequent reads; it cannot retract copies readers already saved.
Existing finite credential ceilings need explicit renewal before using the new
visibility operation; the server does not expand an old credential automatically.

```sh
msg money public-balance u_root
msg money public-ledger '{"subject_id":"u_root","limit":50}'
msg money visibility '{"visibility":"public"}'
msg money visibility '{"visibility":"private"}'
```

Read-only GET/HEAD views are `/@root/public-balance`, `/@root/public-ledger`, and
corresponding `/@handle/` paths. Ledger pagination uses `cursor` (last committed
sequence) and `limit` (1–100); it is ascending and uses integer minor units with
scale 6. Public items contain only transaction ID, sequence, kind, amount,
from/to account IDs and commit time. Counterparty IDs on a published account's
transactions are public even when that counterparty's own account is private;
this does not publish that account's balance or unrelated transactions. Internal
escrow account IDs may appear as counterparties, without order or delivery data.
No free-form reference, request ID, actor, signed receipt or private business
metadata is included. Public projections are informational and are not signed
receipts. The legacy `money.balance@1`, `money.ledger@1`, `/balance` and `/ledger`
remain private owner views, preserving their published schemas and receipt data.
3 changes: 3 additions & 0 deletions src/msg/client_market.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
'banks': ('money.banks', None),
'balance': ('money.balance', None),
'ledger': ('money.ledger', 'json'),
'public-balance': ('money.public_balance', 'subject_id'),
'public-ledger': ('money.public_ledger', 'json'),
'visibility': ('money.visibility_set', 'json'),
'offers': ('money.offers', None),
'entitlements': ('money.entitlements', None),
'transfer': ('money.transfer', 'json'),
Expand Down
2 changes: 1 addition & 1 deletion src/msg/data/recovery-state-schema.json

Large diffs are not rendered by default.

123 changes: 122 additions & 1 deletion src/msg/plugins/money.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from __future__ import annotations

from msg.constants import ROOT_SUBJECT
from msg.core.codec import loads
from msg.core.codec import loads, wire
from msg.core.errors import require
from msg.core.models import HandlerOutput

Expand Down Expand Up @@ -51,6 +51,28 @@
]


async def public_account(tx, subject_id):
"""Current subject state and role govern publication on every read."""
resource = await tx.resource(subject_id)
require(resource.type == 'user' and resource.state == 'active', 'not_found')
require(
tx.one("SELECT 1 FROM identities WHERE id=? AND kind='subject'", (subject_id,)) is not None,
'not_found',
)
subject = await tx.subject(subject_id)
require(
subject_id == ROOT_SUBJECT or subject.kind in {'registered', 'custodial'},
'not_found',
)
bank = tx.one(
"SELECT 1 FROM money_bank_roles WHERE subject_id=? AND status='active'", (subject_id,)
)
policy = tx.one('SELECT visibility FROM money_visibility WHERE subject_id=?', (subject_id,))
mandatory = subject_id == ROOT_SUBJECT or bank is not None
require(mandatory or (policy is not None and policy[0] == 'public'), 'not_found')
return 'root' if subject_id == ROOT_SUBJECT else 'bank' if bank else 'opt_in'


def install(app):
op, finish = registration(app, 'money', ('identity',))
amount_schema = {'type': 'integer', 'minimum': 1, 'maximum': MAX_MINOR}
Expand Down Expand Up @@ -137,6 +159,105 @@ async def ledger(ctx, request, tx):
}
)

@op('money.public_balance', obj({'subject_id': IDENTIFIER}, ('subject_id',)), effect='read')
async def public_balance(ctx, request, tx):
subject = request.arguments['subject_id']
reason = await public_account(tx, subject)
return HandlerOutput(
data={
'subject_id': subject,
'currency_id': CURRENCY_ID,
'scale': SCALE,
'balance_minor': _balance(tx, subject),
'publication': reason,
}
)

@op(
'money.public_ledger',
obj(
{
'subject_id': IDENTIFIER,
'cursor': {'type': 'integer', 'minimum': 0},
'limit': {'type': 'integer', 'minimum': 1, 'maximum': 100},
},
('subject_id',),
),
effect='read',
)
async def public_ledger(ctx, request, tx):
subject = request.arguments['subject_id']
reason = await public_account(tx, subject)
cursor = request.arguments.get('cursor', 0)
limit = request.arguments.get('limit', 50)
# Never load receipts or free-form references into the public projection.
rows = tx.rows(
"""SELECT seq,id,kind,amount_minor,debit_account,credit_account,committed_at
FROM money_ledger WHERE seq>? AND (debit_account=? OR credit_account=?)
ORDER BY seq LIMIT ?""",
(cursor, subject, subject, limit + 1),
)
return HandlerOutput(
data={
'subject_id': subject,
'currency_id': CURRENCY_ID,
'scale': SCALE,
'publication': reason,
'items': [
{
'ledger_sequence': row[0],
'transaction_id': row[1],
'kind': row[2],
'amount_minor': row[3],
'from_account': row[4],
'to_account': row[5],
'committed_at': row[6],
}
for row in rows[:limit]
],
'next_cursor': rows[limit - 1][0] if len(rows) > limit else None,
}
)

@op(
'money.visibility_set',
obj({'visibility': {'enum': ['private', 'public']}}, ('visibility',)),
signature=True,
requirements=account_requirements,
)
async def visibility_set(ctx, request, tx):
subject = _owner(ctx)
identity = await tx.subject(subject)
resource = await tx.resource(subject)
require(
identity.kind in {'registered', 'custodial'}
and not identity.local_only
and resource.state == 'active',
'money_subject_required',
)
visibility = request.arguments['visibility']
tx.execute(
'INSERT INTO money_visibility(subject_id,visibility,updated_at) VALUES (?,?,?) '
'ON CONFLICT(subject_id) DO UPDATE SET visibility=excluded.visibility, '
'updated_at=excluded.updated_at',
(subject, visibility, wire(ctx.now)),
write=True,
)
mandatory = (
tx.one(
"SELECT 1 FROM money_bank_roles WHERE subject_id=? AND status='active'", (subject,)
)
is not None
)
return HandlerOutput(
data={
'subject_id': subject,
'visibility': visibility,
'effective_visibility': 'public' if mandatory else visibility,
'bank_publication_required': mandatory,
}
)

@op(
'money.transfer',
obj(
Expand Down
4 changes: 4 additions & 0 deletions src/msg/storage/postgres.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@
status TEXT NOT NULL CHECK(status IN ('active','revoked')),
granted_at TEXT NOT NULL, granted_by TEXT NOT NULL CHECK(granted_by='u_root'),
revoked_at TEXT);
CREATE TABLE IF NOT EXISTS money_visibility (
subject_id TEXT PRIMARY KEY REFERENCES identities(id),
visibility TEXT NOT NULL CHECK(visibility IN ('private','public')),
updated_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS money_ledger (
seq BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
id TEXT NOT NULL UNIQUE,
Expand Down
12 changes: 9 additions & 3 deletions src/msg/transports/http_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2043,7 +2043,7 @@ async def dispatch(request: Request):
media_type='application/json',
headers=headers,
)
if name in {'bal', 'balance', 'ledger'}:
if name in {'bal', 'balance', 'ledger', 'public-balance', 'public-ledger'}:
require(remainder in {None, '/', '/json'}, 'not_found')
require(b'%' not in raw_path, 'not_found')
operation = subject_route.name
Expand All @@ -2052,7 +2052,9 @@ async def dispatch(request: Request):
len(pairs) == len({key for key, _ in pairs}), 'duplicate_query_parameter'
)
query = dict(pairs)
require(not query or name == 'ledger', 'unknown_query_parameter')
require(
not query or name in {'ledger', 'public-ledger'}, 'unknown_query_parameter'
)
require(set(query) <= {'cursor', 'limit'}, 'unknown_query_parameter')
args = {}
for field in ('cursor', 'limit'):
Expand All @@ -2062,6 +2064,9 @@ async def dispatch(request: Request):
async with service.metadata.transaction(write=False) as tx:
subject_id = await tx.resolve('/@' + handle)
require((await tx.resource(subject_id)).type == 'user', 'not_found')
public = name in {'public-balance', 'public-ledger'}
if public:
args['subject_id'] = subject_id
require(
service.registry.operation(operation).effect == 'read', 'effect_mismatch'
)
Expand All @@ -2081,7 +2086,8 @@ async def dispatch(request: Request):
result = await service.executor.execute(packet, entry='network')
if result.error:
return json_response(result_wire(result), error_status(result.error.code))
require(result.subject == subject_id, 'permission_denied')
if not public:
require(result.subject == subject_id, 'permission_denied')
value = wire(result.data)
payload = canonical(value)
require(len(payload) <= limits.max_response_bytes, 'response_too_large')
Expand Down
2 changes: 2 additions & 0 deletions src/msg/transports/subject_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ def subject_view_operation(path):
if len(parts) == 2:
return 'orders.payment' if parts[1] == '_payment' else 'delivery.get'
return 'orders.get'
if name in {'public-balance', 'public-ledger'}:
return 'money.public_ledger' if name == 'public-ledger' else 'money.public_balance'
if name in {'bal', 'balance', 'ledger'}:
return 'money.ledger' if name == 'ledger' else 'money.balance'
if name in SUBJECT_COLLABORATION_VIEWS:
Expand Down
37 changes: 35 additions & 2 deletions tests/test_complete_recovery_proof.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,18 +51,38 @@ async def test_complete_proof_accepts_exact_disposable_state(complete_state):

@pytest.mark.parametrize(
'change',
['extra_setting', 'extra_table', 'extra_column', 'oauth_state', 'wrong_pin', 'partial'],
[
'extra_setting',
'extra_table',
'extra_column',
'oauth_state',
'money_visibility',
'wrong_pin',
'partial',
],
)
async def test_complete_proof_rejects_unaccounted_state(complete_state, change):
app, root, packet, pin = complete_state
if change in {'extra_setting', 'extra_table', 'extra_column', 'oauth_state'}:
if change in {
'extra_setting',
'extra_table',
'extra_column',
'oauth_state',
'money_visibility',
}:
async with app.metadata.transaction(write=True) as tx:
if change == 'extra_setting':
tx.set_setting('recovery_promotion_attacker', {'authority': True})
elif change == 'extra_table':
tx.execute('CREATE TABLE hidden_authority (id TEXT)', write=True)
elif change == 'extra_column':
tx.execute('ALTER TABLE resources ADD COLUMN hidden_authority TEXT', write=True)
elif change == 'money_visibility':
tx.execute(
'INSERT INTO money_visibility VALUES (?,?,?)',
('u_root', 'public', '2026-09-27T00:00:00Z'),
write=True,
)
else:
tx.execute(
'INSERT INTO oauth_states VALUES (?,?,?,?)',
Expand Down Expand Up @@ -225,6 +245,14 @@ async def test_complete_proof_from_real_backup_restores_exact_inventory(
subject=subject,
)
assert created.status == 'ok'
published = await call(
app,
'money.visibility_set',
{'visibility': 'public'},
key=key,
subject=subject,
)
assert published.status == 'ok'
archive = tmp_path / 'complete.zip'
saved = await backup(app, archive)
packet = await capture(app, root, source_backup_sha256=saved['sha256'], sequence=3)
Expand All @@ -239,6 +267,11 @@ async def test_complete_proof_from_real_backup_restores_exact_inventory(
restored = await open_for_proof(tmp_path / 'complete-etc')
restored.clock = app.clock
try:
async with restored.metadata.transaction(write=False) as tx:
assert (
tx.one('SELECT visibility FROM money_visibility WHERE subject_id=?', (subject,))[0]
== 'public'
)
assert (await promote(restored, packet, pin=pin, signer=root, operator='isolated-fixture'))[
'status'
] == 'recovery_promoted'
Expand Down
Loading
Loading