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
18 changes: 9 additions & 9 deletions .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
name: Claude Code Review

on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
# Disabled for pull_request CI while Claude Code OAuth is not reliable.
# Keep the workflow available for manual runs after the repository secret is fixed.
workflow_dispatch:
Comment thread
knzeng-e marked this conversation as resolved.
inputs:
pr_number:
description: "Pull request number to review"
required: true
type: number

jobs:
claude-review:
Expand Down Expand Up @@ -37,7 +37,7 @@ jobs:
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: |
Review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}.
Review ${{ github.repository }}/pull/${{ inputs.pr_number }}.

Prioritize concrete bugs, security issues, behavioral regressions,
and missing tests. Keep summaries brief, do not modify files, and
Expand Down
25 changes: 25 additions & 0 deletions docs/backlog/implementation/evidence/W13.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,31 @@

## Result and decisions

### Existing-access capture follow-up - 2026-09-27

The payment recovery UX merged in PR #219 at starting dev SHA
`9c25093d7508e643258fd3a66b576a8cea312707`. Its Dev Quality Gates passed.
The follow-up branch `feat/pilot-access-readback` adds an operator-only
read-only entitlement capture when selecting a Classic track after binding
the deployed candidate. Previously that path could play an existing purchase
without exporting its fresh runtime read-back.

The new event is deliberately distinct from payment evidence. It records
paid/playable access without a transaction hash or amount, discards reads
after account/selection/candidate changes, and records unknown values when
RPC reads fail. Browser and CLI key evidence now require the same account,
network, runtime and content hash after the corresponding access read-back.
Signing/value-forwarding gates remain blocked without actual payment evidence.
Capture stays opt-in, bounded and session-local; account/release identifiers
belong in private operator diagnostics, never aggregate participant evidence.

Implementation/tested SHA and validation results are attached to the follow-up
PR after committing. No deployment, transfer, rollback rehearsal or physical
device test is performed by this change. W13 remains `hold`; the next live
step is to publish an authorized candidate and capture existing entitlement,
key delivery and walletless room listening against its exact SHA/version/CID.


W13 adds a concrete release gate instead of a checklist-only pilot plan. The new
`npm run smoke:pilot-release` command reconciles:

Expand Down
8 changes: 8 additions & 0 deletions docs/operations/deployment-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,14 @@ settlement ledger. Enable
`VITE_DOTIFY_DEBUG_PANEL=true` only on that smoke build to export the safe
browser-side evidence bundle with `amountPlanck`, payment read-back, Product
sr25519 key/session outcomes, and the operator-marked host approval observation.
For W13, first bind the deployed candidate and open an already-paid Classic
track. A separate `access-readback` event captures current paid/playable access
without submitting another transaction. Backend-key evidence must follow a
read for the same account, network, runtime and content hash. Existing access
does not satisfy host approval, native transfer or transaction read-back gates.
No new configuration is needed. Capture remains bounded session storage;
reset it after use and keep account-linked diagnostic exports separate from
aggregate pilot evidence. See the Product deployment runbook for the sequence.
Validate Product protected playback through host smoke tests after each Product
publication before treating Product identity as production-ready for gated
listening.
Expand Down
23 changes: 20 additions & 3 deletions docs/operations/product-devnet-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -718,9 +718,26 @@ npm run deploy:product-devnet
malformed CIDs with the standards-compliant IPFS parser, and clears events
whenever SHA, Product appVersion, or CID changes.

Use a funded Product account that has not already paid for the target
Classic track. Do not use this as the default `dotify-test01.dot` release
gate until it has passed once end to end. Verify:
First reuse an existing paid Classic entitlement: open that track with the
connected Product account after binding the deployment. This captures a
read-only `access-readback` event (`hasPaid`, `canAccess`, chain, listener,
runtime and content hash), followed by the normal backend key request.
The browser and CLI require that the key follows the read and matches its
account, network, runtime and track. An RPC failure records unknown access;
changing account, track or capture while reading discards the stale result.
The collector makes no extra reads without a bound capture for this build.

This proves current entitlement/key delivery only. It does not fabricate a
transaction hash, paid amount or host approval. The host-approval,
native-value and payment-readback gates remain blocked in the CLI when
only this read-only evidence is available. Do not pay again merely to make
those gates green. Reset the capture when finished; keep the diagnostic
export private because it links an account to a release, and never merge
it into aggregate participant evidence.

A separately authorized new-payment test still needs a funded Product
account without access to the target Classic track. Do not use this as the
default `dotify-test01.dot` release gate until it has passed end to end. Verify:
- the connected Dotify Product account and the host-selected signer expose
the same public key;
- deriving `pallet-revive` H160 from that public key gives the same H160
Expand Down
12 changes: 12 additions & 0 deletions web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,18 @@ room capture reads creation, stream, peer, listener, and canonical-link facts
from the active host session; the operator confirms walletless guest arrival,
audible audio, and sync on the guest device before export.

For W13, bind the Product deployment first, then open a Classic track that the
connected Product account already paid for. The operator capture reads
`hasPaid` and `canAccess` and exports an `access-readback` event before the key
request. It never submits a payment, and makes no extra reads outside a bound
capture for the current build. Only a subsequent key release for the same
account, network, runtime and track satisfies the backend-key check.
An existing entitlement does not prove a new transaction: approval, native
value and transaction read-back gates remain blocked when only read-only
evidence is supplied. Keep the operator export private because it includes
account/release identifiers; do not include it in aggregate pilot data. Reset
the capture after the check to stop collecting diagnostic events.

### Production Troubleshooting

| Symptom | Likely cause | Check | Fix |
Expand Down
64 changes: 56 additions & 8 deletions web/scripts/product-devnet-journey-harness.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,9 @@ function latestPaymentEvent(events) {
return events.filter(event => event?.kind === 'payment').at(-1) ?? null;
}

function latestAllowedKeyEvent(events, payment, context) {
function latestAllowedKeyEvent(events, access, context) {
const latestKey = events.filter(event => event?.kind === 'key').at(-1);
if (!access || latestKey?.phase === 'key-denied' || latestKey?.phase === 'key-error') return null;
return (
events.find(
event =>
Expand All @@ -451,7 +453,12 @@ function latestAllowedKeyEvent(events, payment, context) {
event.playbackMode === 'full' &&
sameValue(event.address, context?.listenerAddress) &&
sameValue(event.productPublicKey, context?.productPublicKey) &&
(!payment || (sameValue(event.contentHash, payment.contentHash) && sameValue(event.runtime, payment.runtimeAddress)))
Number.isFinite(event.timestamp) &&
Number.isFinite(access.timestamp) &&
event.timestamp >= access.timestamp &&
sameValue(event.contentHash, access.contentHash) &&
sameValue(event.runtime, access.runtimeAddress) &&
sameValue(event.address, access.listenerAddress)
) ?? null
);
}
Expand All @@ -462,10 +469,10 @@ function hasExplicitHostApproval(events) {

function allSmokeIdentitiesMatch(events, context) {
if (!context?.listenerAddress || !context?.productPublicKey) return false;
const identityEvents = events.filter(event => event?.kind === 'payment' || event?.kind === 'key');
const identityEvents = events.filter(event => event?.kind === 'payment' || event?.kind === 'access-readback' || event?.kind === 'key');
if (identityEvents.length === 0) return false;
return identityEvents.every(event => {
if (event.kind === 'payment') return sameValue(event.listenerAddress, context.listenerAddress);
if (event.kind === 'payment' || event.kind === 'access-readback') return sameValue(event.listenerAddress, context.listenerAddress);
return sameValue(event.address, context.listenerAddress) && sameValue(event.productPublicKey, context.productPublicKey);
});
}
Expand All @@ -492,7 +499,9 @@ export function evaluateProductCdmSmokeEvidence(evidence, options = {}) {
const candidate = evidence.candidate ?? {};
const events = smokeEvents(evidence);
const payment = latestPaymentEvent(events);
const allowedKey = latestAllowedKeyEvent(events, payment, context);
const accessReadback = events.filter(event => event?.kind === 'access-readback').at(-1);
const access = events.filter(event => event?.kind === 'payment' || event?.kind === 'access-readback').at(-1);
const allowedKey = latestAllowedKeyEvent(events, access, context);

if (evidence.schemaVersion !== 2) {
fail(gates, 'smoke-schema', 'Smoke evidence schema', `Expected schemaVersion 2, found ${evidence.schemaVersion ?? 'missing'}.`, 'Product host JSON');
Expand Down Expand Up @@ -598,13 +607,25 @@ export function evaluateProductCdmSmokeEvidence(evidence, options = {}) {
if (hasExplicitHostApproval(events)) {
pass(gates, 'smoke:host-approval', 'Host approval', 'Operator recorded an explicit Product host approval prompt.', 'Product host JSON');
} else {
fail(gates, 'smoke:host-approval', 'Host approval', 'Expected an operator-observation event with host-approval-explicit=true.', 'Product host JSON');
(accessReadback && !payment ? blocked : fail)(
gates,
'smoke:host-approval',
'Host approval',
'No explicit transaction approval captured. A read-only access check does not exercise signing.',
'Product host JSON'
);
}

if (payment && isPositivePlanck(payment.amountPlanck)) {
pass(gates, 'smoke:native-value', 'Native value', `amountPlanck=${payment.amountPlanck}.`, 'Product host JSON');
} else {
fail(gates, 'smoke:native-value', 'Native value', 'Expected a payment event with a non-zero native amountPlanck.', 'Product host JSON');
(accessReadback && !payment ? blocked : fail)(
gates,
'smoke:native-value',
'Native value',
'Expected a payment event with a non-zero native amountPlanck.',
'Product host JSON'
);
}

if (
Expand All @@ -621,7 +642,7 @@ export function evaluateProductCdmSmokeEvidence(evidence, options = {}) {
) {
pass(gates, 'smoke:payment-readback', 'Payment read-back', `Access read-back passed after ${payment.attempts} attempts.`, 'Product host JSON');
} else {
fail(
(accessReadback && !payment ? blocked : fail)(
gates,
'smoke:payment-readback',
'Payment read-back',
Expand All @@ -630,6 +651,32 @@ export function evaluateProductCdmSmokeEvidence(evidence, options = {}) {
);
}

if (
access &&
access.hasPaid === true &&
access.canAccess === true &&
isHexAddress(access.runtimeAddress) &&
isHexHash(access.contentHash) &&
sameValue(access.listenerAddress, context.listenerAddress) &&
(access.kind === 'access-readback' ? access.chainId === EXPECTED_PRODUCT_DEVNET.chainId : access.ok === true)
) {
pass(
gates,
'smoke:access-readback',
'Existing paid access',
'The runtime confirms paid access. This alone proves neither a new transfer nor host approval.',
'Product host JSON'
);
} else {
fail(
gates,
'smoke:access-readback',
'Existing paid access',
'Expected paid and playable access for the connected account, network, runtime and track.',
'Product host JSON'
);
}

if (allowedKey) {
pass(
gates,
Expand Down Expand Up @@ -834,6 +881,7 @@ export function buildSurfaceMatrix({ commit, appVersion, productSmokeGates, room
'smoke:host-approval',
'smoke:native-value',
'smoke:payment-readback',
'smoke:access-readback',
'smoke:backend-key',
'smoke:same-identity'
]);
Expand Down
54 changes: 54 additions & 0 deletions web/scripts/product-devnet-journey-harness.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,60 @@ function completeRoomEvidence(overrides = {}) {
};
}

test('existing entitlement and matching key are useful evidence but cannot certify a new Product write', () => {
const evidence = completeSmokeEvidence();
evidence.events = [
{
kind: 'access-readback',
runtimeAddress: RUNTIME,
contentHash: CONTENT_HASH,
listenerAddress: ADDRESS,
chainId: EXPECTED_PRODUCT_DEVNET.chainId,
hasPaid: true,
canAccess: true,
timestamp: 1_000
},
evidence.events[2]
];
const gates = evaluateProductCdmSmokeEvidence(evidence);
for (const id of ['smoke:access-readback', 'smoke:backend-key', 'smoke:same-identity']) {
assert.equal(gates.find(gate => gate.id === id)?.status, 'pass', id);
}
for (const id of ['smoke:host-approval', 'smoke:native-value', 'smoke:payment-readback']) {
assert.equal(gates.find(gate => gate.id === id)?.status, 'blocked', id);
}
});

test('a different network or unpaid access does not satisfy the entitlement gate', () => {
for (const patch of [{ chainId: 1 }, { hasPaid: false }, { canAccess: false }, { hasPaid: null }, { listenerAddress: RUNTIME }]) {
const evidence = completeSmokeEvidence();
evidence.events.unshift({
kind: 'access-readback',
runtimeAddress: RUNTIME,
contentHash: CONTENT_HASH,
listenerAddress: ADDRESS,
chainId: EXPECTED_PRODUCT_DEVNET.chainId,
hasPaid: true,
canAccess: true,
timestamp: 1_000,
...patch
});
evidence.events = evidence.events.filter(event => event.kind !== 'payment');
assert.equal(evaluateProductCdmSmokeEvidence(evidence).find(gate => gate.id === 'smoke:access-readback')?.status, 'fail');
}
});

test('an old key or later denial cannot certify the selected entitlement', () => {
for (const patch of [{ timestamp: 500 }, { runtime: ADDRESS }, { contentHash: TX_HASH }, { chainId: 1 }]) {
const evidence = completeSmokeEvidence();
Object.assign(evidence.events[2], patch);
assert.equal(evaluateProductCdmSmokeEvidence(evidence).find(gate => gate.id === 'smoke:backend-key')?.status, 'fail');
}
const evidence = completeSmokeEvidence();
evidence.events.push({ ...evidence.events[2], phase: 'key-denied', timestamp: 3_000 });
assert.equal(evaluateProductCdmSmokeEvidence(evidence).find(gate => gate.id === 'smoke:backend-key')?.status, 'fail');
});

test('parseEnvFile ignores comments and preserves empty values', () => {
assert.deepEqual(
parseEnvFile(`
Expand Down
74 changes: 74 additions & 0 deletions web/src/features/productHost/productAccessReadback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import * as evidence from './productCdmHostSmokeEvidence';
import { captureProductAccessReadback } from './productAccessReadback';

const candidate = { gitSha: 'a'.repeat(40), productAppVersion: '[0, 1, 29]', deployedCid: 'test-candidate' };
function fixture() {
const session: evidence.ProductCdmHostSmokeSession = { schemaVersion: 2, startedAt: '2026-09-27T12:00:00Z', candidate, events: [] };
const getSession = vi.spyOn(evidence, 'getProductCdmHostSmokeSession').mockReturnValue(session);
const publish = vi.spyOn(evidence, 'publishProductAccessReadbackMetric').mockImplementation(() => {});
const input = {
reader: { hasPaid: vi.fn(async () => true), canAccess: vi.fn(async () => true) },
buildSha: candidate.gitSha,
productAppVersion: candidate.productAppVersion,
runtimeAddress: `0x${'11'.repeat(20)}` as const,
contentHash: `0x${'22'.repeat(32)}` as const,
listenerAddress: `0x${'33'.repeat(20)}` as const,
chainId: 420420417,
isCurrent: vi.fn(() => true)
};
return { session, getSession, publish, input };
}
afterEach(() => vi.restoreAllMocks());

describe('Product read-only access capture', () => {
it('reads the exact release without a writer and exports no payment amount or hash', async () => {
const { input, publish } = fixture();
await captureProductAccessReadback(input);
for (const read of [input.reader.hasPaid, input.reader.canAccess]) {
expect(read).toHaveBeenCalledExactlyOnceWith(input.runtimeAddress, input.contentHash, input.listenerAddress);
}
expect(publish).toHaveBeenCalledExactlyOnceWith({
runtimeAddress: input.runtimeAddress,
contentHash: input.contentHash,
listenerAddress: input.listenerAddress,
chainId: input.chainId,
hasPaid: true,
canAccess: true,
timestamp: expect.any(Number)
});
});

it.each(['unbound', 'other-build', 'other-version', 'stale-selection'])('does not read or collect outside a current capture: %s', reason => {
const { input, getSession, publish } = fixture();
if (reason === 'unbound') getSession.mockReturnValue(null);
if (reason === 'other-build') input.buildSha = 'b'.repeat(40);
if (reason === 'other-version') input.productAppVersion = '[0, 1, 28]';
if (reason === 'stale-selection') input.isCurrent.mockReturnValue(false);
return captureProductAccessReadback(input).then(() => {
expect(input.reader.hasPaid).not.toHaveBeenCalled();
expect(input.reader.canAccess).not.toHaveBeenCalled();
expect(publish).not.toHaveBeenCalled();
});
});

it.each(['account', 'reset', 'rebound'])('discards a read when the %s changes while pending', async change => {
const { input, publish, session, getSession } = fixture();
input.reader.hasPaid.mockImplementation(async () => {
if (change === 'account') input.isCurrent.mockReturnValue(false);
if (change === 'reset') getSession.mockReturnValue(null);
if (change === 'rebound') getSession.mockReturnValue({ ...session, candidate: { ...candidate, deployedCid: 'another-candidate' } });
return true;
});
await captureProductAccessReadback(input);
expect(publish).not.toHaveBeenCalled();
});

it('records unknown results on RPC failure without exposing the raw error or preventing playback', async () => {
const { input, publish } = fixture();
input.reader.hasPaid.mockRejectedValue(new Error('private endpoint details'));
await expect(captureProductAccessReadback(input)).resolves.toBeUndefined();
expect(publish).toHaveBeenCalledWith(expect.objectContaining({ hasPaid: null, canAccess: null }));
expect(JSON.stringify(publish.mock.calls)).not.toContain('private endpoint details');
});
});
Loading
Loading