From 9d9a3fa6de12fa16de9a33717405c89a523588cc Mon Sep 17 00:00:00 2001
From: ltardivo
Date: Wed, 5 Aug 2026 16:55:18 -0700
Subject: [PATCH 1/6] fix(lib): require confirmed revs for load and revision
graph
Guard first, prev, next, last, and load with confirmed-location checks;
require confirmed successors/spends for next/last. Expand InnerComputer
tests for unconfirmed starts, mempool next, spent last, and sync.
fix (monorepo): Adapt apps and docs to confirmed InnerComputer queries
Mine after deletes before finalWithdraw; confirm modules and mints in
tests. Chess helper waits for tip confirmation before withdraw/cancel
refund. Document observation stability and contract-vs-client computer
APIs in docs and docs-2.
---
packages/TBC777/src/tbc777.ts | 18 +-
packages/TBC777/src/tbc777m.ts | 9 +-
packages/TBC777/test/tbc777.test.ts | 9 +-
packages/TBC777/test/tbc777m.test.ts | 2 +
.../chess-app/src/components/ChessBoard.tsx | 16 +-
packages/chess-contracts/README.md | 7 +-
.../chess-contracts/src/chess-contract.ts | 76 +++++--
.../test/chess-contract.test.ts | 55 +++++
packages/commodity/test/commodity.test.ts | 14 ++
.../sandbox-and-inner-computer.md | 66 ++++++
packages/docs-2/docs/concepts/how-it-works.md | 21 +-
packages/docs-2/docs/intro.md | 11 +-
packages/docs/Lib/Computer/decode.md | 4 +
packages/docs/Lib/Computer/first.md | 4 +
packages/docs/Lib/Computer/getAncestors.md | 9 +
packages/docs/Lib/Computer/getTxos.md | 10 +
packages/docs/Lib/Computer/latest.md | 4 +
packages/docs/Lib/Computer/load.md | 4 +
packages/docs/Lib/Computer/next.md | 8 +
packages/docs/Lib/Computer/prev.md | 6 +
packages/docs/Lib/Computer/sync.md | 4 +
packages/docs/Lib/Contract/index.md | 189 ++++++++----------
22 files changed, 401 insertions(+), 145 deletions(-)
diff --git a/packages/TBC777/src/tbc777.ts b/packages/TBC777/src/tbc777.ts
index ba6775d8f..aba545a22 100644
--- a/packages/TBC777/src/tbc777.ts
+++ b/packages/TBC777/src/tbc777.ts
@@ -83,8 +83,8 @@ export type ClaimAmountEntry = [Id, Amount]
* the specific `escrowRev` revision supplied to `audit()`. Escrow
* implementations should record final-withdrawal entries only in their
* terminal (latest) revision. The `finalWithdraw()` method on tokens
- * additionally verifies that the supplied revision is currently the live tip
- * of the escrow via `computer.last(rev)`.
+ * additionally verifies the tip via `computer.last(rev)` (requires that tip
+ * to be spent in a **confirmed** transaction — see `finalWithdraw`).
*
* SECURITY INVARIANT: Even if an escrow is buggy or malicious and
* over-authorizes claims, the audited balance for any token lineage can never
@@ -492,11 +492,15 @@ export class TBC777 extends TBC20 {
/**
* Claim a final withdrawal from the given escrow revision.
*
- * Performs an explicit terminal-revision check via `computer.last(rev)`. The
- * supplied revision must be the current live tip of the escrow at the moment
- * of evaluation. This check is intentionally omitted from regular
- * `withdraw()` and `getBalance()` so those paths stay free of transient
- * observations and remain deterministic under chain extension.
+ * Performs an explicit terminal-revision check via `computer.last(rev)`.
+ * InnerComputer only returns a definite `last` when the tip is **spent in a
+ * confirmed transaction** (mempool-only or unspent tips invalidate). Typical
+ * flow: record `finalWithdraws` on the tip, `delete` that tip UTXO, wait for
+ * confirmation, then `finalWithdraw(tipRev)`.
+ *
+ * This check is intentionally omitted from regular `withdraw()` and
+ * `getBalance()` so those paths stay free of transient observations and
+ * remain deterministic under chain extension.
*/
async finalWithdraw(rev: Rev) {
return this._withdraw(rev, true)
diff --git a/packages/TBC777/src/tbc777m.ts b/packages/TBC777/src/tbc777m.ts
index 154c7fdb5..fb9a27463 100644
--- a/packages/TBC777/src/tbc777m.ts
+++ b/packages/TBC777/src/tbc777m.ts
@@ -207,9 +207,12 @@ export class TBC777M extends TBC20 {
* Returns the amount this specific token instance (`_id`) is allowed to
* withdraw according to the escrow's `finalWithdraws` list. The amount is
* only returned if the supplied `rev` is the final (last) revision of the
- * escrow (checked via `computer.last(rev)`); otherwise returns `0n`. Only
- * matching entries for the token’s root and id are summed. Intended for
- * one-time final payouts (e.g. winner-takes-all).
+ * escrow (checked via `computer.last(rev)`).
+ *
+ * Note: InnerComputer `last` invalidates the transition when the tip is still
+ * unspent or only spent in the mempool. Callers must use a tip whose spend is
+ * confirmed (same protocol as TBC777 `finalWithdraw`). When `last` succeeds
+ * but points at a different rev, this returns `0n`.
*/
static async computeFinalWithdraw(rev: string, _id: string, _root: string): Promise {
if ((await computer.last(rev)) !== rev) return 0n
diff --git a/packages/TBC777/test/tbc777.test.ts b/packages/TBC777/test/tbc777.test.ts
index 1ec56a8ab..e395d3538 100644
--- a/packages/TBC777/test/tbc777.test.ts
+++ b/packages/TBC777/test/tbc777.test.ts
@@ -67,6 +67,8 @@ describe('TBC777 - Programmable Escrow Token (No-Inflation Focus)', () => {
export ${escrowSource}
export ${tbc777Source}
`)
+ // Confirm module so InnerComputer.load(mod) (semantic isEqualTo) is stable.
+ await minter.db.wallet.restClient.mine(1)
await ensureFunds(minter, 20e8)
@@ -336,13 +338,16 @@ describe('TBC777 - Programmable Escrow Token (No-Inflation Focus)', () => {
// Tip is still live/unspent → computer.last(firstRev) returns undefined →
// InnerComputer invalidates with the framework non-existent-state message.
// (The domain "can only be claimed from last revision" message only appears
- // after the tip has been deleted, when last() returns a concrete tip rev.)
+ // after the tip has been deleted *and that spend is confirmed*, when last()
+ // returns a concrete tip rev.)
expect(e.message).to.include(
'Accessing non-existent on-chain state inside a smart contract is forbidden',
)
}
await minter.delete([lastRev])
+ // last() requires a confirmed spending input of the tip — mine the delete.
+ await mine()
await t.finalWithdraw(lastRev)
expect(t.amount).to.eq(FRESH_TOKEN_AMOUNT)
@@ -952,6 +957,8 @@ describe('TBC777 - Programmable Escrow Token (No-Inflation Focus)', () => {
}
await minter.delete([lastRev])
+ // last() requires a confirmed spending input of the tip — mine the delete.
+ await mine()
await t.finalWithdraw(lastRev)
expect(t.amount).to.eq(FRESH_TOKEN_AMOUNT)
diff --git a/packages/TBC777/test/tbc777m.test.ts b/packages/TBC777/test/tbc777m.test.ts
index e71ba9c53..e87726680 100644
--- a/packages/TBC777/test/tbc777m.test.ts
+++ b/packages/TBC777/test/tbc777m.test.ts
@@ -44,6 +44,8 @@ describe('TBC777M', () => {
await Promise.all([black.faucet(10e8), white.faucet(1e8), minter.faucet(10e8)])
await ensureFunds(minter)
mod = await minter.deploy(`export ${TBC20}`)
+ // Confirm module deploy so any InnerComputer.load of `mod` is stable.
+ await mine()
})
it('Should work for a naive escrow', async () => {
diff --git a/packages/chess-app/src/components/ChessBoard.tsx b/packages/chess-app/src/components/ChessBoard.tsx
index ecb529726..762b396dd 100644
--- a/packages/chess-app/src/components/ChessBoard.tsx
+++ b/packages/chess-app/src/components/ChessBoard.tsx
@@ -211,7 +211,8 @@ function WinnerModal(data: {
Prize: {data.wagerAmount} tokens
- Click "Withdraw Tokens" on the board to collect your prize.
+ Click "Withdraw Tokens" on the board to collect your prize. Withdrawal waits
+ until the final game transaction is confirmed on-chain.
) : (
@@ -282,11 +283,10 @@ function ActionButtons({
)}
- {/* Withdraw Tokens: shown only when the chess contract declares a payout
- for my token AND my token has not already claimed against the current
- chess revision. On checkmate the winning move sets withdraws
- atomically, so the winner can withdraw immediately — no separate
- "Claim Win" round trip. */}
+ {/* Withdraw Tokens: shown when the chess contract declares a payout for my
+ token and it has not been claimed yet. The helper waits for the game
+ tip to confirm before TBC777 withdraw (InnerComputer history must be
+ confirmed). */}
{isPayoutEligible && !hasWithdrawn && (
Withdraw Tokens
@@ -610,6 +610,8 @@ export function ChessBoard() {
const myPubKey = computer.getPublicKey()
const myTokenId =
myPubKey === chessContract.publicKeyW ? chessContract.tokenIdW : chessContract.tokenIdB
+ // Helper waits for the latest chess tip to confirm before auditing deposits.
+ showSnackBar('Waiting for the game result to confirm, then withdrawing…', true)
await helper.withdrawTokens(myTokenId, chessContract._id)
await syncChessContract()
notifyGamesUpdated()
@@ -641,6 +643,8 @@ export function ChessBoard() {
try {
setIsCancelling(true)
showLoader(true)
+ // Cancel sets withdraws, then waits for confirmation, then TBC777 withdraw.
+ showSnackBar('Cancelling challenge and waiting for confirmation before refund…', true)
await helper.cancelGameAndWithdraw(chessContract._id)
if (document.getElementById(winnerModal)) {
Modal.hideModal(winnerModal)
diff --git a/packages/chess-contracts/README.md b/packages/chess-contracts/README.md
index 119cf0537..98e864269 100644
--- a/packages/chess-contracts/README.md
+++ b/packages/chess-contracts/README.md
@@ -124,6 +124,8 @@ class ChessContract extends Contract {
resign(): void
isGameOver(): boolean
hasTimedOutW / hasTimedOutB(): Promise
+ // Uses InnerComputer.txIdToBlockTime + prev on the full prev-chain.
+ // All revisions including the tip must be **confirmed** or the call is rejected.
calculateTimes(): Promise<{ timeW: bigint; timeB: bigint }>
setCanceledSeen(): void
}
@@ -133,9 +135,10 @@ class ChessContractHelper {
depositTokens(chessRev, tokenRev, wagerAmount, name, nextOwner, coSign?): Promise
move(chessId, from, to, promotion?): Promise<{ newChessContract; isGameOver }>
resign(chessId): Promise
- withdrawTokens(tokenId, chessId): Promise
+ withdrawTokens(tokenId, chessId): Promise // waits for chess tip confirmation first
cancelGame(chessId): Promise
- cancelGameAndWithdraw(chessId): Promise
+ cancelGameAndWithdraw(chessId): Promise // cancel → wait for confirm → withdraw
+ waitForConfirmed(location): Promise
markCanceledSeen(chessId): Promise
// plus query helpers (isGameStarted, canCancel, isCreator, …)
}
diff --git a/packages/chess-contracts/src/chess-contract.ts b/packages/chess-contracts/src/chess-contract.ts
index 1edf3bff1..88155bbc3 100644
--- a/packages/chess-contracts/src/chess-contract.ts
+++ b/packages/chess-contracts/src/chess-contract.ts
@@ -149,29 +149,47 @@ export class ChessContract extends Contract {
return new Chess(this.fen).isGameOver()
}
+ /**
+ * Whether white has exceeded `timeLimit` (based on confirmed block times).
+ * Requires every revision on the prev-chain (including the current tip) to be
+ * confirmed — see `calculateTimes`.
+ */
async hasTimedOutW(): Promise {
const { timeW } = await this.calculateTimes()
return timeW > this.timeLimit
}
+ /**
+ * Whether black has exceeded `timeLimit` (based on confirmed block times).
+ * Requires every revision on the prev-chain (including the current tip) to be
+ * confirmed — see `calculateTimes`.
+ */
async hasTimedOutB(): Promise {
const { timeB } = await this.calculateTimes()
return timeB > this.timeLimit
}
/**
- * Calculates white time (timeW) and black time (timeB) from a list of timestamps.
+ * Calculates white time (timeW) and black time (timeB) from block timestamps
+ * of each revision on the game’s prev-chain.
+ *
* timeW = (t2 - t1) + (t4 - t3) + (t6 - t5) + ...
* timeB = (t3 - t2) + (t5 - t4) + (t7 - t6) + ...
*
* Note: timeW + timeB will equal (tn - t1).
+ *
+ * **InnerComputer determinism:** uses `computer.txIdToBlockTime` and
+ * `computer.prev`. Both require **confirmed** transactions. Calling this (or
+ * `hasTimedOutW` / `hasTimedOutB`) while the tip is still in the mempool
+ * invalidates the transition. Callers must wait for confirmation of the
+ * latest move (and of any older history being walked) before evaluating
+ * timeouts on-chain.
*/
async calculateTimes(): Promise<{ timeW: bigint; timeB: bigint }> {
let current = this._rev
const timestamps: bigint[] = []
- // Collect every historical state. Deposits and withdrawals accumulate
- // across the entire lifetime of the escrow, so the audit must see them all.
+ // Walk tip → root. Every step must be a confirmed revision.
while (true) {
const txId = current.split(':')[0]
timestamps.push(await computer.txIdToBlockTime(txId))
@@ -349,12 +367,45 @@ export class ChessContractHelper {
return { newChessContract, isGameOver }
}
+ /**
+ * Poll until `location` (txId or rev) is included in a block.
+ * Required before TBC777 `withdraw` / InnerComputer history walks: unconfirmed
+ * tips invalidate deterministic queries (`sync` / `prev` / `next` / block time).
+ */
+ async waitForConfirmed(
+ location: string,
+ opts?: { timeoutMs?: number; pollMs?: number },
+ ): Promise {
+ const txId = location.includes(':') ? location.split(':')[0]! : location
+ const timeoutMs = opts?.timeoutMs ?? 180_000
+ const pollMs = opts?.pollMs ?? 1_500
+ const start = Date.now()
+ while (Date.now() - start < timeoutMs) {
+ try {
+ const blockHash = await this.computer.txIdToBlockHash(txId)
+ if (blockHash) return
+ } catch {
+ // not yet known / not confirmed
+ }
+ await new Promise((r) => setTimeout(r, pollMs))
+ }
+ throw new Error(
+ `Timed out waiting for confirmation of ${txId}. Try again after the transaction is mined.`,
+ )
+ }
+
+ /**
+ * Claim escrow payout for `tokenId` against the latest chess revision.
+ * Waits until that chess tip is confirmed so TBC777's InnerComputer audit
+ * (sync / prev / next on deposits) is deterministic.
+ */
async withdrawTokens(tokenId: string, chessId: string): Promise {
const latestTokenRev = await this.computer.latest(tokenId)
const latestChessRev = await this.computer.latest(chessId)
if (!this.tokenMod) {
throw new Error('tokenMod is required for TBC777 withdraw')
}
+ await this.waitForConfirmed(latestChessRev)
const { tx } = await this.computer.encode({
exp: `token.withdraw('${latestChessRev}')`,
env: { token: latestTokenRev },
@@ -448,17 +499,16 @@ export class ChessContractHelper {
}
/**
- * Cancel a pending game and withdraw the creator's wager in one flow.
- * @deprecated
- * */
+ * Cancel a pending game and withdraw the creator's wager.
+ *
+ * Cancel and withdraw cannot share one transaction: after cancel, the tip must
+ * be **confirmed** before TBC777 `withdraw` can walk escrow history. This
+ * method cancels, waits for confirmation, then withdraws.
+ */
async cancelGameAndWithdraw(chessId: string): Promise {
- await this.cancelGame(chessId)
- /**
- * Under the strict non-determinism guarantee we cannot cancel and withdraw
- * in one atomic action anymore. We now have to wait for the next block
- * before we withdraw. The app needs to be adapted accordingly.
- * */
- // await this.withdrawTokens(chess.tokenIdW, chessId)
+ const chess = await this.cancelGame(chessId)
+ await this.waitForConfirmed(chess._rev)
+ await this.withdrawTokens(chess.tokenIdW, chessId)
}
/** Mark a canceled pending game as seen by the invited opponent (clears list badge). */
diff --git a/packages/chess-contracts/test/chess-contract.test.ts b/packages/chess-contracts/test/chess-contract.test.ts
index e3230a645..bf36da5ad 100644
--- a/packages/chess-contracts/test/chess-contract.test.ts
+++ b/packages/chess-contracts/test/chess-contract.test.ts
@@ -541,6 +541,61 @@ describe('ChessContract', () => {
expect(blackTokenFinal.amount).toBe(15n)
})
+ it('calculateTimes / hasTimedOut require a confirmed tip (InnerComputer guards)', async () => {
+ const wager = 5n
+ const timeLimit = 60n * 10n
+ const { chess, chessFunded } = await fundChessGame({
+ minter,
+ white,
+ black,
+ tbc777Mod,
+ chessMod,
+ wager,
+ timeLimit,
+ })
+
+ // One unconfirmed move: tip has no block time yet → must invalidate.
+ const head = await white.latest(chess._id)
+ const toMove = await white.sync(head)
+ const { tx: moveTx, effect: moveEffect } = await white.encodeCall({
+ target: toMove,
+ property: 'move',
+ args: ['e2', 'e4', ''],
+ mod: chessMod,
+ })
+ await white.broadcast(moveTx)
+ const afterMove = (moveEffect as unknown as { env: { __bc__: unknown } }).env
+ .__bc__ as SmartContract
+ expect(afterMove._rev).not.toBe(chessFunded._rev)
+
+ await expect(async () => {
+ const { tx } = await white.encodeCall({
+ target: afterMove,
+ property: 'hasTimedOutW',
+ args: [],
+ mod: chessMod,
+ })
+ await white.broadcast(tx)
+ }).rejects.toThrow(
+ /Accessing non-existent on-chain state inside a smart contract is forbidden/,
+ )
+
+ // After the move confirms, walking prev + block times is a stable observation.
+ await minter.db.wallet.restClient.mine(1)
+ const confirmed = await white.sync(afterMove._rev)
+ const { tx: okTx, effect: okEffect } = await white.encodeCall({
+ target: confirmed,
+ property: 'hasTimedOutW',
+ args: [],
+ mod: chessMod,
+ })
+ await white.broadcast(okTx)
+ const timedOut = (okEffect as unknown as { res: boolean }).res
+ expect(typeof timedOut).toBe('boolean')
+ // Fresh game with one move should not exceed a 10-minute limit.
+ expect(timedOut).toBe(false)
+ })
+
it('Should run fool mate and credit winner balance on withdraw', async () => {
await minter.faucet(1e8)
const wager = 5n
diff --git a/packages/commodity/test/commodity.test.ts b/packages/commodity/test/commodity.test.ts
index 2a2ccc580..bf93350cd 100644
--- a/packages/commodity/test/commodity.test.ts
+++ b/packages/commodity/test/commodity.test.ts
@@ -579,6 +579,20 @@ describe('Commodity – Canonical Min-Revision Digital Commodity', function () {
// 6. claim() – Tier 1: eligibility guards
// =========================================================================
describe('claim() – Tier 1: eligibility guards (no same-block control required)', () => {
+ it('throws if claim() is called before the mint creation tx is confirmed', async () => {
+ // claim() uses InnerComputer.txIdToBlockHeight / decode / getOTXOs — all
+ // require a confirmed creation tx. Unconfirmed mints must fail closed.
+ const local = await fundedComputer()
+ const localMod = await deployCommodity(local)
+ await mineBlocks(local, 1)
+ const mint = await createMint(local, localMod, 'unconfirmed-claim-salt')
+ // Intentionally do not mine / confirmMint before claim.
+ await expectClaimFails(
+ mint,
+ /Accessing non-existent on-chain state inside a smart contract is forbidden/,
+ )
+ })
+
it('throws if called when _rev !== _root (not on the mint creation revision)', async () => {
const mint = await createMint(alice, mod)
await mint.transfer(bob.getPublicKey())
diff --git a/packages/docs-2/docs/architecture/sandbox-and-inner-computer.md b/packages/docs-2/docs/architecture/sandbox-and-inner-computer.md
index e69de29bb..5689e7f38 100644
--- a/packages/docs-2/docs/architecture/sandbox-and-inner-computer.md
+++ b/packages/docs-2/docs/architecture/sandbox-and-inner-computer.md
@@ -0,0 +1,66 @@
+---
+title: "Sandbox & Inner Computer"
+---
+
+# Sandbox & Inner Computer
+
+Contract methods run inside a restricted SES compartment. The only chain-facing
+API available to that code is the **InnerComputer** (`computer` global): a
+read-only, fail-closed view of confirmed blockchain state.
+
+## Goals
+
+1. **Determinism** — If a query succeeds against a chain prefix, the same call
+ must succeed with the same result on every extension of that chain.
+2. **Fail closed** — Transient facts (mempool, “not yet”, future heights) never
+ become part of a valid transition.
+3. **No silent soft-fail** — Catching a thrown error does not clear invalidation;
+ `Db.eval` still rejects the transition if the invalid flag is set.
+
+## Observation stability
+
+For any InnerComputer method `m` and arguments `args`: if
+`computer.m(...args)` succeeds without setting the invalid flag against chain
+state `b₁`, then on any extension `b₂ ⊇ b₁` the same call must succeed and
+return the same value.
+
+## Confirmed locations
+
+Most APIs require the referenced transaction to be **in a block** before the
+call may succeed:
+
+| Family | Rule (summary) |
+| --- | --- |
+| `sync` / `decode` / `load` / `getAncestors` | Start location/tx confirmed |
+| `first` / `prev` | Start rev confirmed; `prev` may return `undefined` at root |
+| `next` | Start and **result** confirmed; no next → invalidate |
+| `last` | Start and result confirmed; tip must be **spent confirmed** (not live unspent tip) |
+| Block time/height/hash of a tx | Tx confirmed |
+| `getBlockHash(height)` | Height ≤ tip; not future |
+| `getTXOs` (+ aliases) | Stabilizer: `lteBlockHeight` / `blockHeight` / `blockHash` |
+
+`latest` is **not** exposed inside contracts.
+
+## Invalidation flow
+
+1. Query fails or observes a transient fact.
+2. InnerComputer sets `globalInvalidState` and throws.
+3. Compartment returns (possibly after `catch`).
+4. `Db.eval` sees the flag and rejects the transition.
+
+## Client vs contract `computer`
+
+The outer [Computer](/docs/reference/computer-class) client may return
+`undefined` for unconfirmed data and supports writes (`new`, `broadcast`, …).
+The in-contract global is a different, stricter surface. Full method tables and
+examples live in the library Contract reference (Retype docs:
+`Lib/Contract` — Querying inside of a Contract).
+
+## Practical implications
+
+- Confirm deploys before `load` in contracts.
+- Confirm object revisions before history walks or escrow audits.
+- For terminal `last` checks, spend the tip and wait for confirmation.
+- Stabilize in-contract TXO queries with a historical height or block hash.
+- Escrow/chess apps: cancel or settle, wait for confirmation, then
+ `withdraw` / refund.
diff --git a/packages/docs-2/docs/concepts/how-it-works.md b/packages/docs-2/docs/concepts/how-it-works.md
index b46a8165e..2a4218a94 100644
--- a/packages/docs-2/docs/concepts/how-it-works.md
+++ b/packages/docs-2/docs/concepts/how-it-works.md
@@ -58,7 +58,14 @@ Inside every contract method you have access to a global `computer` object that
provides deterministic, read-only access to on-chain state. You can fetch other
smart objects with `sync(rev)`, inspect historical transactions with
`decode(txId)`, traverse ancestor chains, follow revision history with
-`first`/`prev`/`next`/`latest`, and retrieve block times with `txIdToBlockTime`.
+`first`/`prev`/`next`/`last`, load confirmed modules with `load`, query TXOs
+with stabilizing height/hash filters via `getTXOs`, and read block times with
+`txIdToBlockTime`.
+
+Successful observations must be **stable under chain extension**: unconfirmed
+(mempool) locations, “no next revision yet”, and unspent tips as `last` all
+**invalidate** the evaluation (a `try/catch` cannot clear that flag).
+`latest` is **not** available inside contracts for this reason.
Because every participant executes exactly the same sequence of deterministic
queries when replaying a transaction, the system remains safe and predictable.
@@ -170,11 +177,13 @@ blockchain operations and provides full IDE support.
[^6]:
Inside contract methods the `computer` global exposes only deterministic,
- read-only on-chain queries. Any failure during such a query (missing
- revision, RPC error, etc.) raises an internal invalidation flag
- (`globalInvalidState` in `inner-computer.ts`). After the secure compartment
- returns, `Db.eval` inspects the flag and rejects the entire transition if it
- was set. Controlled mutations required for reconstruction and metadata
+ read-only on-chain queries. Any failure or transient observation (missing
+ revision, unconfirmed location, no next successor yet, unspent tip for
+ `last`, unguarded/future `getTXOs`, RPC error, etc.) raises an internal
+ invalidation flag (`globalInvalidState` in `inner-computer.ts`). After the
+ secure compartment returns, `Db.eval` inspects the flag and rejects the
+ entire transition if it was set—even when the contract caught the thrown
+ error. Controlled mutations required for reconstruction and metadata
attachment are performed under an explicit privilege guard (`_sudo` /
`AdminContext` in `admin.ts`) that restores the normal security invariants
afterward.
diff --git a/packages/docs-2/docs/intro.md b/packages/docs-2/docs/intro.md
index 57da15af6..71db283eb 100644
--- a/packages/docs-2/docs/intro.md
+++ b/packages/docs-2/docs/intro.md
@@ -226,11 +226,12 @@ class Escrow extends Contract {
**Important distinction**: The `computer` available _inside_ your contract
methods is a restricted `InnerComputer`. It only exposes safe read operations
-(`sync`, `decode`, `load`, `first`/`prev`/`next`/`last`, `getAncestors`, etc.).
-It cannot create or broadcast transactions. This is a deliberate security
-boundary. The implementation also uses a global invalid-state flag as a safety
-net: if a contract attempts to access non-existent on-chain state, the entire
-evaluation is rejected.
+(`sync`, `decode`, `load`, `first`/`prev`/`next`/`last`, `getAncestors`,
+block/time helpers, guarded `getTXOs`, etc.). It cannot create or broadcast
+transactions, and it does **not** expose `latest`. Reads generally require
+**confirmed** locations; missing, mempool-only, or other transient observations
+set an invalidation flag and reject the entire evaluation even if the contract
+catches the thrown error.
### Low-Level Control for Complex Protocols
diff --git a/packages/docs/Lib/Computer/decode.md b/packages/docs/Lib/Computer/decode.md
index 5a8d466ea..e18251288 100644
--- a/packages/docs/Lib/Computer/decode.md
+++ b/packages/docs/Lib/Computer/decode.md
@@ -34,6 +34,10 @@ An object containing the following properties:
The `decode` function takes a Bitcoin transaction or a transaction ID as input and retrieves the associated metadata if the transaction is a Bitcoin Computer transaction. This metadata includes the JavaScript expression, any environment variables, and an optional module specifier.
+### Inside smart contracts (`InnerComputer`)
+
+Only **confirmed** transactions may be decoded. Unconfirmed or missing txIds invalidate the evaluation. See [Contract – Querying](../Contract/index.md#querying-inside-of-a-contract).
+
## Example
:::code source="../../../lib/test/lib/computer/decode.test.ts" :::
diff --git a/packages/docs/Lib/Computer/first.md b/packages/docs/Lib/Computer/first.md
index 5149ca0a4..8c96ec068 100644
--- a/packages/docs/Lib/Computer/first.md
+++ b/packages/docs/Lib/Computer/first.md
@@ -22,6 +22,10 @@ Returns the first revision of the same on chain object, that is, its id.
If `first` is called with a revision for which no output exists, it throws an error `Rev not found`. If the output exists but contains no object, the same error is thrown. If the output contains an object, `first` will return the first revision of an object as indicated by the arrows in the figure below.
+### Inside smart contracts (`InnerComputer`)
+
+The starting revision must be **confirmed**. Missing or unconfirmed starts invalidate the contract evaluation. See [Contract – Querying](../Contract/index.md#querying-inside-of-a-contract).
+
[](https://wallet.bitcoincomputer.io)
## Example
diff --git a/packages/docs/Lib/Computer/getAncestors.md b/packages/docs/Lib/Computer/getAncestors.md
index 14ea1d1c5..d9f65c50c 100644
--- a/packages/docs/Lib/Computer/getAncestors.md
+++ b/packages/docs/Lib/Computer/getAncestors.md
@@ -19,6 +19,15 @@ computer.getAncestors(rev)
computer.getAncestors(rev, 1)
```
+### Inside smart contracts (`InnerComputer`)
+
+- Starting location must be **confirmed**.
+- Empty arrays are valid stable results when there are no ancestors.
+- Missing or unconfirmed starts invalidate the evaluation.
+- Verbosity maps are a client-side convenience; contracts typically use the default `string[]` form.
+
+See [Contract – Querying](../Contract/index.md#querying-inside-of-a-contract).
+
## Example
:::code source="../../../lib/test/lib/computer/get-ancestors.test.ts" :::
diff --git a/packages/docs/Lib/Computer/getTxos.md b/packages/docs/Lib/Computer/getTxos.md
index 4046f067a..ed1e22eae 100644
--- a/packages/docs/Lib/Computer/getTxos.md
+++ b/packages/docs/Lib/Computer/getTxos.md
@@ -89,6 +89,16 @@ The `getTXOs` function retrieves transaction outputs (TXOs) from the database ba
For security and efficiency, always pair this with `publicKey` to scope results to a specific owner, and apply `limit`/`offset` to manage result volume.
+### Inside smart contracts (`InnerComputer`)
+
+Off-chain clients may call `getTXOs` with any filter set. **Inside a contract**, the query must include a **stabilizing** filter so results cannot change as the chain grows:
+
+- `lteBlockHeight` (must be ≤ current tip; future heights are forbidden)
+- `blockHeight` (must be ≤ current tip)
+- `blockHash` (fixed historical block)
+
+Queries without one of these filters invalidate the evaluation. Aliases `getUTXOs`, `getOTXOs`, and `getOUTXOs` inherit the same rule. See [Contract – Querying](../Contract/index.md#querying-inside-of-a-contract).
+
To retrieve Unspent Transaction Outputs, see the syntactic sugar function `getUTXOs`, which internally calls `getTXOs` with the `isSpent: false` parameter.
To retrieve Bitcoin Computer objects, see the syntactic sugar function `getOTXOs`, which internally calls `getTXOs` with the `isObject: true` parameter.
diff --git a/packages/docs/Lib/Computer/latest.md b/packages/docs/Lib/Computer/latest.md
index bd7e11f49..da864f334 100644
--- a/packages/docs/Lib/Computer/latest.md
+++ b/packages/docs/Lib/Computer/latest.md
@@ -22,6 +22,10 @@ Returns the latest revision of the same on chain object - that is, its id.
If `latest` is called with a revision for which no output exists, it throws an error `Rev not found`. If the output exists but contains no object, the same error is thrown. If the output contains an object, `latest` will return the latest revision of an object as indicated by the arrows in the figure below.
+### Not available inside smart contracts
+
+`latest` is **not** exposed on InnerComputer (the in-contract `computer` global). The live tip can change under chain extension, so it is unsuitable for deterministic contract evaluation. Inside contracts, use confirmed history (`first` / `prev` / `getAncestors`) or terminal checks via `last` after a confirmed spend. See [Contract – Querying](../Contract/index.md#querying-inside-of-a-contract).
+
[](https://wallet.bitcoincomputer.io)
## Example
diff --git a/packages/docs/Lib/Computer/load.md b/packages/docs/Lib/Computer/load.md
index 5b51ee2e7..780ae15f5 100644
--- a/packages/docs/Lib/Computer/load.md
+++ b/packages/docs/Lib/Computer/load.md
@@ -18,6 +18,10 @@ A module specifier encoded as a string of the form `: If `computer.m(...args)` **succeeds without invalidation** against `b₁`, then the same call against `b₂` must succeed and return the **same value**.
-> "Accessing non-existent on-chain state inside a smart contract is forbidden."
+Successful observations must therefore be **invariant under future chain growth**. Transient facts (mempool-only txs, “no next revision yet”, unspent tip as “last”, future block heights) must not become part of a valid transition.
-This acts as a hard safety boundary: contracts cannot silently read missing data or depend on off-chain assumptions.
+### How invalidation works
-### Full API Reference
+- On a forbidden observation, InnerComputer sets an internal invalid flag and throws.
+- A contract `try/catch` **cannot** clear that flag. After the SES compartment returns, `Db.eval` still rejects the transition if the flag is set.
+- Error messages end with:
-All query functions available inside `Contract` methods (injected via the secure `InnerComputer` compartment):
+ > Accessing non-existent on-chain state inside a smart contract is forbidden.
-| Function | Signature | Returns | Invalidates on missing / error? | Notes |
-| ----------------- | ------------------------------------------------------------- | ------------------------------------ | ------------------------------------ | ------------------------------------------------------------- |
-| `sync` | `sync(location: string): Promise` | The latest object state | Yes | Deep-cloned with BigInt support |
-| `decode` | `decode(txId: string): Promise` | `{ exp, env?, mod? }` metadata | Yes | Normalizes `mod` to `undefined` if absent |
-| `load` | `load(location: string): Promise>` | Module exports namespace | Yes | For dynamic module loading inside contracts |
-| `getAncestors` | `getAncestors(location: string): Promise` | Array of ancestor locations | Yes (on error) | Empty array `[]` if no ancestors |
-| `first` | `first(rev: string): Promise` | First revision in lineage | Yes | Always returns a string for valid input |
-| `prev` | `prev(rev: string): Promise` | Previous revision or `undefined` | Only on underlying error | Safe to call on tip; returns `undefined` without invalidation |
-| `next` | `next(rev: string): Promise` | Next revision or throws | **Yes, including if no next exists** | Strict: absence of next **invalidates** execution |
-| `last` | `last(rev: string): Promise` | Latest (tip) revision or `undefined` | Only on underlying error | Returns tip of lineage |
-| `txIdToBlockTime` | `txIdToBlockTime(txId: string): Promise` | Block time as `bigint` | Yes | Requires mined Bitcoin Computer transaction |
+- The flag is reset under admin privilege at the start of each evaluation.
-**Quick tip:** Use `getAncestors`, `first`, `last`, and `prev` for safe traversal. Be cautious with `next()` — it will invalidate the contract if you call it on the current tip revision.
+### Confirmed locations only
-### Detailed Function Documentation
+Most location-based APIs require the referenced **transaction to be confirmed** (in a block) before the call may succeed. Unconfirmed / mempool locations are treated as transient.
-#### `sync`
+| API | Confirmation rule |
+| --- | --- |
+| `sync`, `decode`, `getAncestors`, `getRawTransaction` | Starting location / txId must be confirmed |
+| `load` | Module deploy location (`txId:vout`) must be confirmed |
+| `first`, `prev` | Starting revision’s tx must be confirmed |
+| `next` | Starting revision **and** returned successor must be confirmed |
+| `last` | Starting revision, returned tip, and the tip’s **spending** tx must be confirmed (unspent tip or mempool-only spend → invalidate) |
+| `txIdToBlockTime` | Tx must be confirmed (no nullish “not mined yet”) |
+| `txIdToBlockHeight` / `txIdToBlockHash` | Unconfirmed → invalidate (via throw / nullish fail-closed) |
+| `getTXOs` (+ `getUTXOs` / `getOTXOs` / `getOUTXOs`) | Must include a **stabilizing filter** (below); future heights forbidden |
-Returns the latest on-chain state for the given `location` (revision identifier) as a plain JavaScript object.
+**App / test implication:** after `deploy`, `new`, method calls, or `delete`, wait for confirmation before on-chain code that walks history, loads modules, or calls `last` / `next` / `txIdToBlockTime` on those locations.
-```ts
-sync(location: string): Promise
-```
+### Full API reference (InnerComputer)
-- Performs a deep clone (via `stringify`/`parse` with BigInt support) so contracts receive a plain, side-effect-free object.
-- If the location does not exist or cannot be synced, the evaluation is **invalidated** and an error is thrown.
+| Function | Signature | Returns | Invalidates when |
+| --- | --- | --- | --- |
+| `sync` | `sync(location: string)` | Object state (deep-cloned) | Missing / unconfirmed location |
+| `decode` | `decode(txId: string)` | `{ exp, env?, mod? }` | Missing / unconfirmed tx |
+| `load` | `load(location: string)` | Module exports | Missing / unconfirmed module location |
+| `getAncestors` | `getAncestors(location: string)` | `string[]` (may be empty) | Missing / unconfirmed start; empty array is **valid** |
+| `first` | `first(rev: string)` | Creation rev (`string`) | Missing / unconfirmed start |
+| `prev` | `prev(rev: string)` | `string \| undefined` | Missing / unconfirmed start; **`undefined` at root is OK** |
+| `next` | `next(rev: string)` | next rev (`string`) | No next yet; unconfirmed start or unconfirmed successor |
+| `last` | `last(rev: string)` | Spent tip rev (`string`) | Unspent tip; mempool-only spend; unconfirmed start/result |
+| `txIdToBlockTime` | `txIdToBlockTime(txId: string)` | block time | Unconfirmed or missing tx |
+| `txIdToBlockHeight` | `txIdToBlockHeight(txId: string)` | height | Unconfirmed or missing tx |
+| `txIdToBlockHash` | `txIdToBlockHash(txId: string)` | block hash | Unconfirmed or missing tx |
+| `getBlockHash` | `getBlockHash(height: number)` | hash | Negative or **future** height; missing block |
+| `getBlockHeight` | `getBlockHeight(hash: string)` | height | Unknown hash |
+| `getRawTransaction` | `getRawTransaction(txId: string)` | hex | Unconfirmed / missing |
+| `getRawBlock` / `getBlockHeader` | by block hash | hex | Unknown hash |
+| `getTXOs` | `getTXOs(q: TXOQuery)` | revs or records | No stabilizer; future/negative height filters; query failure |
-#### `decode`
+Aliases `getUTXOs`, `getOTXOs`, and `getOUTXOs` inherit the same rules as `getTXOs`.
-Parses a Bitcoin transaction ID and returns its Bitcoin Computer metadata if it is a valid Bitcoin Computer transaction.
+**`latest` is not exposed** on InnerComputer (the live tip is inherently non-deterministic under chain extension).
-```ts
-decode(txId: string): Promise
-```
+### Detailed notes
-Returned shape (approximate):
-
-```ts
-{
- exp: string
- env?: { [s: string]: string }
- mod?: string
-}
-```
-
-- If `mod` is falsy in the raw transition, it is normalized to `undefined`.
-- If the `txId` is malformed or does not correspond to a Bitcoin Computer transaction, the evaluation is **invalidated**.
-
-#### `load`
-
-Dynamically loads a module by its specifier (location) and returns its exports.
+#### `sync` / `decode` / `load`
```ts
+sync(location: string): Promise
+decode(txId: string): Promise
load(location: string): Promise>
```
-- Useful for importing shared contract logic or libraries inside a contract method.
-- If the module specifier is invalid or the module cannot be loaded, the evaluation is **invalidated**.
+- `sync` deep-clones the object (BigInt-safe) so contracts cannot mutate live graph state.
+- `decode` requires a confirmed Bitcoin Computer transaction.
+- `load` accepts a module rev (`txId:outputIndex`); the deploy transaction must be confirmed.
+- Missing or unconfirmed targets invalidate the evaluation.
#### `getAncestors`
-Returns the full ancestry chain for a revision/location as an array of revision identifiers.
-
```ts
getAncestors(location: string): Promise
```
-- Returns `[]` if there are no ancestors.
-- If the location is invalid or cannot be resolved, the evaluation is **invalidated**.
-- This is the recommended safe way to traverse history without risking invalidation from `next()`.
-
-#### `first`
+- Starting location must be confirmed.
+- An empty array is a stable result when there are no ancestors; it does **not** require special “allow null” handling (empty arrays are not nullish).
-Returns the very first (root/original) revision in the lineage of the given revision.
+#### `first` / `prev` / `next` / `last`
```ts
first(rev: string): Promise
-```
-
-- Always returns a `string` for a valid revision.
-- Invalidates the evaluation if the revision does not exist.
-
-#### `prev`
-
-Returns the immediately preceding revision in the lineage, or `undefined` if the given revision is the first in its chain.
-
-```ts
prev(rev: string): Promise
+next(rev: string): Promise
+last(rev: string): Promise
```
-- Safe to call on any revision: if there is no previous revision, it returns `undefined` **without** invalidating the contract.
-- Only invalidates on malformed input or internal retrieval errors.
-
-#### `next`
+- **`first`**: starting rev must be confirmed; returns the creation revision.
+- **`prev`**: starting rev must be confirmed. `undefined` at the **confirmed root** is stable and does **not** invalidate. This is the only nullish success path among these helpers.
+- **`next`**: “no successor yet” is transient → **invalidates**. A mempool-only successor also invalidates; the returned next rev must be confirmed.
+- **`last`**: does **not** mean “current unspent tip”. An unspent tip yields `undefined` from the underlying API and **invalidates**. A definite last is the tip of a lineage whose tip is **spent in a confirmed transaction** (e.g. after a confirmed `delete`). Use this for terminal escrow checks, not for reading the live tip.
-Returns the immediately following revision in the lineage.
+#### Block and time helpers
```ts
-next(rev: string): Promise
+txIdToBlockTime(txId: string): Promise
+txIdToBlockHeight(txId: string): Promise
+txIdToBlockHash(txId: string): Promise
+getBlockHash(height: number): Promise
+getBlockHeight(hash: string): Promise
```
-- **Critical behavior**: If there is no next revision (i.e. you are at the tip of the lineage), this function **invalidates the entire contract evaluation** and throws.
-- This is stricter than `prev()`. Use it only when you are certain a next revision must exist, or prefer `last()` / `getAncestors()` for safer traversal.
-- Invalidates on malformed input or retrieval errors as well.
+- Unconfirmed transactions cannot be observed as stable times/heights/hashes.
+- `getBlockHash` rejects negative heights and heights **greater than the current tip** (future blocks are non-deterministic).
-#### `last`
-
-Returns the most recent (tip) revision in the lineage of the given revision.
+#### `getTXOs` (and aliases)
```ts
-last(rev: string): Promise
+getTXOs(q: TXOQuery): Promise
```
-- Returns the current latest revision for that object lineage.
-- Only invalidates on errors retrieving the lineage; may return `undefined` in edge cases without invalidation.
-
-#### `txIdToBlockTime`
+Inside a contract the query **must** include one stabilizing filter:
-Returns the Unix block time (as `bigint`) at which the given transaction was included in a block.
-
-```ts
-txIdToBlockTime(txId: string): Promise
-```
+- `lteBlockHeight` — must be ≤ current tip (not in the future)
+- `blockHeight` — must be ≤ current tip
+- `blockHash` — fixed historical block
-- Useful for time-based logic inside contracts (e.g. vesting, expiration, chess time locks).
-- If the transaction is not found or has no associated block time (e.g. unmined or non-Bitcoin-Computer tx), the evaluation is **invalidated**.
+Queries without a stabilizer, or with a future height, invalidate. Empty result sets with a valid stabilizer are fine.
-### Usage Notes & Best Practices
+### Usage notes & best practices
-1. **Prefer safe traversal methods**: Use `getAncestors()`, `first()`, `last()`, and `prev()` when exploring history. Reserve `next()` for cases where you have already verified a successor must exist.
-2. **Handle `undefined`**: Several functions (`prev`, `last`, possibly `txIdToBlockTime`) can legitimately return `undefined`. Always check before using the result.
+1. **Confirm before query.** Deploy modules, create objects, update or delete tips, then wait for confirmation before contract methods that call InnerComputer on those locations.
+2. **Prefer `prev` / `getAncestors` / `first` for history walks.** Use `next` only when a confirmed successor must exist (e.g. deposit pre/post pair).
+3. **Do not treat `last` as “latest live tip”.** For terminal claims, spend the tip (e.g. `delete`) and wait for confirmation, then call `last`.
+4. **`try/catch` does not soft-fail invalidation.** Catching the throw still rejects the transition if the invalid flag was set.
+5. **Stabilize TXO queries** with `lteBlockHeight`, `blockHeight`, or `blockHash`.
+6. **Off-chain code** using the outer `Computer` may still see mempool data; only the in-contract `computer` global enforces these rules.
-This querying API, combined with the strict property update rules of `Contract`, enables powerful, verifiable, and safe on-chain logic while protecting the network from non-deterministic or invalid contract executions.
+This API, together with `Contract` property rules, enables verifiable on-chain logic while keeping evaluations fail-closed under non-deterministic observations.
From d492e00167f495892479814cf7128e043bf9b7fe Mon Sep 17 00:00:00 2001
From: ltardivo
Date: Wed, 5 Aug 2026 20:29:47 -0700
Subject: [PATCH 2/6] fix(lib): de-dupe InnerComputer invalidation error suffix
Add formatInvalidStateError so the standard forbidden phrase is appended
at most once from _safeCall and Db.eval. Cover uncaught, catch-and-continue, and short policy messages in tests.
(docs): document single-suffix InnerComputer invalidation errors
Update Contract querying and docs-2 intro/how-it-works/sandbox pages so public invalidation messages always end with one forbidden suffix on
uncaught and catch-and-continue paths.
---
.../sandbox-and-inner-computer.md | 15 +++-
packages/docs-2/docs/concepts/how-it-works.md | 10 ++-
packages/docs-2/docs/intro.md | 4 +-
packages/docs/Lib/Contract/index.md | 74 ++++++++++---------
4 files changed, 61 insertions(+), 42 deletions(-)
diff --git a/packages/docs-2/docs/architecture/sandbox-and-inner-computer.md b/packages/docs-2/docs/architecture/sandbox-and-inner-computer.md
index 5689e7f38..20ce1cd91 100644
--- a/packages/docs-2/docs/architecture/sandbox-and-inner-computer.md
+++ b/packages/docs-2/docs/architecture/sandbox-and-inner-computer.md
@@ -44,9 +44,18 @@ call may succeed:
## Invalidation flow
1. Query fails or observes a transient fact.
-2. InnerComputer sets `globalInvalidState` and throws.
-3. Compartment returns (possibly after `catch`).
-4. `Db.eval` sees the flag and rejects the transition.
+2. InnerComputer sets `globalInvalidState` (with a reason message) and throws.
+3. Compartment returns (possibly after `catch` — the flag is **not** cleared).
+4. `Db.eval` sees the flag and rejects the transition with a single public error
+ string.
+
+Error text always ends with exactly one copy of:
+
+> Accessing non-existent on-chain state inside a smart contract is forbidden.
+
+Context from the failing query (or a short policy reason such as “future
+height”) is included at most once before that suffix. Catch-and-continue and
+uncaught paths do **not** double the forbidden sentence.
## Client vs contract `computer`
diff --git a/packages/docs-2/docs/concepts/how-it-works.md b/packages/docs-2/docs/concepts/how-it-works.md
index 2a4218a94..a79fc60ae 100644
--- a/packages/docs-2/docs/concepts/how-it-works.md
+++ b/packages/docs-2/docs/concepts/how-it-works.md
@@ -183,10 +183,12 @@ blockchain operations and provides full IDE support.
invalidation flag (`globalInvalidState` in `inner-computer.ts`). After the
secure compartment returns, `Db.eval` inspects the flag and rejects the
entire transition if it was set—even when the contract caught the thrown
- error. Controlled mutations required for reconstruction and metadata
- attachment are performed under an explicit privilege guard (`_sudo` /
- `AdminContext` in `admin.ts`) that restores the normal security invariants
- afterward.
+ error. The public error message ends with a single standard suffix
+ (“Accessing non-existent on-chain state inside a smart contract is
+ forbidden.”). Controlled mutations required for
+ reconstruction and metadata attachment are performed under an explicit
+ privilege guard (`_sudo` / `AdminContext` in `admin.ts`) that restores the
+ normal security invariants afterward.
[^7]:
The `SmartContract` type is produced by a covariant recursive lifting
diff --git a/packages/docs-2/docs/intro.md b/packages/docs-2/docs/intro.md
index 71db283eb..c8e0f0998 100644
--- a/packages/docs-2/docs/intro.md
+++ b/packages/docs-2/docs/intro.md
@@ -231,7 +231,9 @@ block/time helpers, guarded `getTXOs`, etc.). It cannot create or broadcast
transactions, and it does **not** expose `latest`. Reads generally require
**confirmed** locations; missing, mempool-only, or other transient observations
set an invalidation flag and reject the entire evaluation even if the contract
-catches the thrown error.
+catches the thrown error. Rejected evaluations surface an error that ends with
+a single standard phrase: “Accessing non-existent on-chain state inside a smart
+contract is forbidden.”
### Low-Level Control for Complex Protocols
diff --git a/packages/docs/Lib/Contract/index.md b/packages/docs/Lib/Contract/index.md
index 5ca2c5f39..8b73114b7 100644
--- a/packages/docs/Lib/Contract/index.md
+++ b/packages/docs/Lib/Contract/index.md
@@ -90,51 +90,57 @@ Successful observations must therefore be **invariant under future chain growth*
### How invalidation works
-- On a forbidden observation, InnerComputer sets an internal invalid flag and throws.
-- A contract `try/catch` **cannot** clear that flag. After the SES compartment returns, `Db.eval` still rejects the transition if the flag is set.
-- Error messages end with:
+1. On a forbidden observation, InnerComputer sets an internal invalid flag and throws an `Error`.
+2. A contract `try/catch` **cannot** clear that flag. After the SES compartment returns, `Db.eval` still rejects the transition if the flag is set.
- > Accessing non-existent on-chain state inside a smart contract is forbidden.
+#### Error message shape
-- The flag is reset under admin privilege at the start of each evaluation.
+All invalidation errors exposed to callers end with **exactly one** copy of:
+
+> Accessing non-existent on-chain state inside a smart contract is forbidden.
+
+- Direct policy rejections (for example, future `getBlockHash` height, missing `getTXOs` stabilizer) store a short reason; when the contract catches and continues, `Db.eval` re-throws via a shared formatter so the standard suffix is still present **once**.
+- Uncaught throws and catch-and-continue paths therefore share the same single-suffix convention (no doubled “forbidden” text).
+
+Clients and tests should match with `message.endsWith(...)` (or equivalent), not assume a doubled suffix.
### Confirmed locations only
Most location-based APIs require the referenced **transaction to be confirmed** (in a block) before the call may succeed. Unconfirmed / mempool locations are treated as transient.
-| API | Confirmation rule |
-| --- | --- |
-| `sync`, `decode`, `getAncestors`, `getRawTransaction` | Starting location / txId must be confirmed |
-| `load` | Module deploy location (`txId:vout`) must be confirmed |
-| `first`, `prev` | Starting revision’s tx must be confirmed |
-| `next` | Starting revision **and** returned successor must be confirmed |
-| `last` | Starting revision, returned tip, and the tip’s **spending** tx must be confirmed (unspent tip or mempool-only spend → invalidate) |
-| `txIdToBlockTime` | Tx must be confirmed (no nullish “not mined yet”) |
-| `txIdToBlockHeight` / `txIdToBlockHash` | Unconfirmed → invalidate (via throw / nullish fail-closed) |
-| `getTXOs` (+ `getUTXOs` / `getOTXOs` / `getOUTXOs`) | Must include a **stabilizing filter** (below); future heights forbidden |
+| API | Confirmation rule |
+| ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
+| `sync`, `decode`, `getAncestors`, `getRawTransaction` | Starting location / txId must be confirmed |
+| `load` | Module deploy location (`txId:vout`) must be confirmed |
+| `first`, `prev` | Starting revision’s tx must be confirmed |
+| `next` | Starting revision **and** returned successor must be confirmed |
+| `last` | Starting revision, returned tip, and the tip’s **spending** tx must be confirmed (unspent tip or mempool-only spend → invalidate) |
+| `txIdToBlockTime` | Tx must be confirmed (no nullish “not mined yet”) |
+| `txIdToBlockHeight` / `txIdToBlockHash` | Unconfirmed → invalidate (via throw / nullish fail-closed) |
+| `getTXOs` (+ `getUTXOs` / `getOTXOs` / `getOUTXOs`) | Must include a **stabilizing filter** (below); future heights forbidden |
**App / test implication:** after `deploy`, `new`, method calls, or `delete`, wait for confirmation before on-chain code that walks history, loads modules, or calls `last` / `next` / `txIdToBlockTime` on those locations.
### Full API reference (InnerComputer)
-| Function | Signature | Returns | Invalidates when |
-| --- | --- | --- | --- |
-| `sync` | `sync(location: string)` | Object state (deep-cloned) | Missing / unconfirmed location |
-| `decode` | `decode(txId: string)` | `{ exp, env?, mod? }` | Missing / unconfirmed tx |
-| `load` | `load(location: string)` | Module exports | Missing / unconfirmed module location |
-| `getAncestors` | `getAncestors(location: string)` | `string[]` (may be empty) | Missing / unconfirmed start; empty array is **valid** |
-| `first` | `first(rev: string)` | Creation rev (`string`) | Missing / unconfirmed start |
-| `prev` | `prev(rev: string)` | `string \| undefined` | Missing / unconfirmed start; **`undefined` at root is OK** |
-| `next` | `next(rev: string)` | next rev (`string`) | No next yet; unconfirmed start or unconfirmed successor |
-| `last` | `last(rev: string)` | Spent tip rev (`string`) | Unspent tip; mempool-only spend; unconfirmed start/result |
-| `txIdToBlockTime` | `txIdToBlockTime(txId: string)` | block time | Unconfirmed or missing tx |
-| `txIdToBlockHeight` | `txIdToBlockHeight(txId: string)` | height | Unconfirmed or missing tx |
-| `txIdToBlockHash` | `txIdToBlockHash(txId: string)` | block hash | Unconfirmed or missing tx |
-| `getBlockHash` | `getBlockHash(height: number)` | hash | Negative or **future** height; missing block |
-| `getBlockHeight` | `getBlockHeight(hash: string)` | height | Unknown hash |
-| `getRawTransaction` | `getRawTransaction(txId: string)` | hex | Unconfirmed / missing |
-| `getRawBlock` / `getBlockHeader` | by block hash | hex | Unknown hash |
-| `getTXOs` | `getTXOs(q: TXOQuery)` | revs or records | No stabilizer; future/negative height filters; query failure |
+| Function | Signature | Returns | Invalidates when |
+| -------------------------------- | --------------------------------- | -------------------------- | ------------------------------------------------------------ |
+| `sync` | `sync(location: string)` | Object state (deep-cloned) | Missing / unconfirmed location |
+| `decode` | `decode(txId: string)` | `{ exp, env?, mod? }` | Missing / unconfirmed tx |
+| `load` | `load(location: string)` | Module exports | Missing / unconfirmed module location |
+| `getAncestors` | `getAncestors(location: string)` | `string[]` (may be empty) | Missing / unconfirmed start; empty array is **valid** |
+| `first` | `first(rev: string)` | Creation rev (`string`) | Missing / unconfirmed start |
+| `prev` | `prev(rev: string)` | `string \| undefined` | Missing / unconfirmed start; **`undefined` at root is OK** |
+| `next` | `next(rev: string)` | next rev (`string`) | No next yet; unconfirmed start or unconfirmed successor |
+| `last` | `last(rev: string)` | Spent tip rev (`string`) | Unspent tip; mempool-only spend; unconfirmed start/result |
+| `txIdToBlockTime` | `txIdToBlockTime(txId: string)` | block time | Unconfirmed or missing tx |
+| `txIdToBlockHeight` | `txIdToBlockHeight(txId: string)` | height | Unconfirmed or missing tx |
+| `txIdToBlockHash` | `txIdToBlockHash(txId: string)` | block hash | Unconfirmed or missing tx |
+| `getBlockHash` | `getBlockHash(height: number)` | hash | Negative or **future** height; missing block |
+| `getBlockHeight` | `getBlockHeight(hash: string)` | height | Unknown hash |
+| `getRawTransaction` | `getRawTransaction(txId: string)` | hex | Unconfirmed / missing |
+| `getRawBlock` / `getBlockHeader` | by block hash | hex | Unknown hash |
+| `getTXOs` | `getTXOs(q: TXOQuery)` | revs or records | No stabilizer; future/negative height filters; query failure |
Aliases `getUTXOs`, `getOTXOs`, and `getOUTXOs` inherit the same rules as `getTXOs`.
@@ -210,7 +216,7 @@ Queries without a stabilizer, or with a future height, invalidate. Empty result
1. **Confirm before query.** Deploy modules, create objects, update or delete tips, then wait for confirmation before contract methods that call InnerComputer on those locations.
2. **Prefer `prev` / `getAncestors` / `first` for history walks.** Use `next` only when a confirmed successor must exist (e.g. deposit pre/post pair).
3. **Do not treat `last` as “latest live tip”.** For terminal claims, spend the tip (e.g. `delete`) and wait for confirmation, then call `last`.
-4. **`try/catch` does not soft-fail invalidation.** Catching the throw still rejects the transition if the invalid flag was set.
+4. **`try/catch` does not soft-fail invalidation.** Catching the throw still rejects the transition if the invalid flag was set. The public error still ends with a **single** “Accessing non-existent…” suffix (whether the throw was uncaught or re-raised by `Db.eval`).
5. **Stabilize TXO queries** with `lteBlockHeight`, `blockHeight`, or `blockHash`.
6. **Off-chain code** using the outer `Computer` may still see mempool data; only the in-contract `computer` global enforces these rules.
From c339b866570c0f904e8f17b2a78075fbb860a14b Mon Sep 17 00:00:00 2001
From: ltardivo
Date: Thu, 6 Aug 2026 12:24:32 -0700
Subject: [PATCH 3/6] fix(lib): evaluate-scoped invalidation stack for
InnerComputer
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
SES free-variable `computer` is bound to the definition compartment
(create / Modules.load), which is often a different InnerComputer instance
than the one Db.eval endows for the current call. A pure instance flag
therefore missed catch-and-continue invalidations after V4 instance-local
state.
Introduce beginEvalInvalidation/endEvalInvalidation stack frames as the
authoritative invalidation record: _invalidate marks the current top frame
(and mirrors on the instance). Db.eval and Modules.load each push/pop around evaluate/import so concurrent Promise.all evals cannot cross-talk, while module bodies that call computer still participate.
Tests cover free-var mismatch, nested frames, concurrent unfunded encode isolation, and module-deployed classes with try/catch still rejected after evaluate returns.
docs: document eval-stack invalidation and SES free-var computer
Explain that invalidation is tracked per evaluation frame (push/pop in
Db.eval and Modules.load), not a process-global flag, so concurrent
evaluations stay isolated. Note that free-variable computer may resolve to
the create-time or module-load instance while the active frame still
records invalidation—catch-and-continue cannot soft-succeed. Update
Lib/Contract querying and docs-2 sandbox architecture accordingly.
---
.../docs/architecture/sandbox-and-inner-computer.md | 13 +++++++++----
packages/docs/Lib/Contract/index.md | 5 +++--
2 files changed, 12 insertions(+), 6 deletions(-)
diff --git a/packages/docs-2/docs/architecture/sandbox-and-inner-computer.md b/packages/docs-2/docs/architecture/sandbox-and-inner-computer.md
index 20ce1cd91..0703e9b4a 100644
--- a/packages/docs-2/docs/architecture/sandbox-and-inner-computer.md
+++ b/packages/docs-2/docs/architecture/sandbox-and-inner-computer.md
@@ -44,10 +44,15 @@ call may succeed:
## Invalidation flow
1. Query fails or observes a transient fact.
-2. InnerComputer sets `globalInvalidState` (with a reason message) and throws.
-3. Compartment returns (possibly after `catch` — the flag is **not** cleared).
-4. `Db.eval` sees the flag and rejects the transition with a single public error
- string.
+2. InnerComputer marks the **current evaluation-stack frame** invalid and
+ throws. Frames are push/pop around each `Db.eval` and `Modules.load` so
+ concurrent async work cannot cross-talk. (Free-var `computer` may be a
+ create/module instance different from the eval endowment; the stack still
+ records invalidation for the active evaluation.)
+3. Compartment returns (possibly after `catch` — the frame flag is **not**
+ cleared until the frame is popped after the invalidity check).
+4. `Db.eval` / `Modules.load` read the frame (via `computer.isInvalid`) and
+ reject with a single public error string.
Error text always ends with exactly one copy of:
diff --git a/packages/docs/Lib/Contract/index.md b/packages/docs/Lib/Contract/index.md
index 8b73114b7..709789357 100644
--- a/packages/docs/Lib/Contract/index.md
+++ b/packages/docs/Lib/Contract/index.md
@@ -90,8 +90,9 @@ Successful observations must therefore be **invariant under future chain growth*
### How invalidation works
-1. On a forbidden observation, InnerComputer sets an internal invalid flag and throws an `Error`.
-2. A contract `try/catch` **cannot** clear that flag. After the SES compartment returns, `Db.eval` still rejects the transition if the flag is set.
+1. On a forbidden observation, InnerComputer marks the **current evaluation-stack frame** invalid and throws. Frames are pushed/popped around each `Db.eval` evaluate and each `Modules.load` import (not a process-wide singleton), so concurrent evaluations cannot cross-talk.
+2. Free-variable `computer` in methods may be the create-time or module-load instance (SES lexical binding), different from the eval endowment. Invalidation still applies to the **active frame**, so catch-and-continue cannot soft-succeed.
+3. A contract `try/catch` **cannot** clear the flag (reset requires admin privilege). After the compartment returns, `Db.eval` rejects the transition if the active frame is invalid.
#### Error message shape
From 2d24fa99f0e96b196755ec42522cb0c02dc44904 Mon Sep 17 00:00:00 2001
From: ltardivo
Date: Thu, 6 Aug 2026 16:08:55 -0700
Subject: [PATCH 4/6] fix(lib): browser-safe eval frames and canonical
InnerComputer errors
Replace the plain invalidation stack with withEvalInvalidation dual backends:
Node uses AsyncLocalStorage via process.getBuiltinModule('async_hooks') (no
static node:async_hooks import), and the browser uses an await-scoped stack
with serialized root frames so concurrent Promise.all evals cannot cross-talk
under SES lockdown.
Harden the host path and error shape:
- Db.eval prefers frame.invalid / frame.msg on both compartment throw and
catch-and-continue return, not computer.isInvalid alone.
- _invalidate and _safeCall always apply formatInvalidStateError (single
forbidden suffix; policy reasons never stand alone).
- _ensureConfirmedTx treats any falsy block hash as unconfirmed.
- createInnerComputerEndowment remains a hardened public-method facade.
Stabilize InnerComputer tests against indexer lag and mixed unconfirmed
dispatch (confirm the query object first; poll outer getTXOs before asserting
in-contract results; expectSingleForbiddenSuffix everywhere).
Includes modules.ts wiring for load-time frames.
(docs): align InnerComputer sandbox docs with eval-frame implementation
Document dual-runtime evaluation frames (Node ALS without a static node:async_hooks import; browser stack + serialized roots), host reject on
frame.invalid for throw and catch-and-continue, and the canonical single forbidden-suffix error shape for policy and missing observations.
- Add Lib/Contract/sandbox-and-inner-computer.md and link it from Contract querying, Lib index, how-it-works, and language.
- Refresh Contract querying invalidation / error / getTXOs wording.
- Sync docs-2 architecture sandbox page, intro, and how-it-works notes with the same model (drop obsolete plain eval-stack-only wording).
---
.../sandbox-and-inner-computer.md | 53 +++++++------
packages/docs-2/docs/concepts/how-it-works.md | 22 +++---
packages/docs-2/docs/intro.md | 9 ++-
packages/docs/Lib/Contract/index.md | 26 ++++---
.../Contract/sandbox-and-inner-computer.md | 78 +++++++++++++++++++
packages/docs/Lib/index.md | 4 +-
packages/docs/how-it-works.md | 8 ++
packages/docs/language.md | 2 +
8 files changed, 154 insertions(+), 48 deletions(-)
create mode 100644 packages/docs/Lib/Contract/sandbox-and-inner-computer.md
diff --git a/packages/docs-2/docs/architecture/sandbox-and-inner-computer.md b/packages/docs-2/docs/architecture/sandbox-and-inner-computer.md
index 0703e9b4a..19e261ba3 100644
--- a/packages/docs-2/docs/architecture/sandbox-and-inner-computer.md
+++ b/packages/docs-2/docs/architecture/sandbox-and-inner-computer.md
@@ -8,6 +8,10 @@ Contract methods run inside a restricted SES compartment. The only chain-facing
API available to that code is the **InnerComputer** (`computer` global): a
read-only, fail-closed view of confirmed blockchain state.
+The live Retype reference lives under `packages/docs/Lib/Contract/`
+([querying](../../../docs/Lib/Contract/index.md#querying-inside-of-a-contract) and
+[sandbox](../../../docs/Lib/Contract/sandbox-and-inner-computer.md)).
+
## Goals
1. **Determinism** — If a query succeeds against a chain prefix, the same call
@@ -15,14 +19,14 @@ read-only, fail-closed view of confirmed blockchain state.
2. **Fail closed** — Transient facts (mempool, “not yet”, future heights) never
become part of a valid transition.
3. **No silent soft-fail** — Catching a thrown error does not clear invalidation;
- `Db.eval` still rejects the transition if the invalid flag is set.
+ the host still rejects if the evaluation frame is marked invalid.
## Observation stability
For any InnerComputer method `m` and arguments `args`: if
-`computer.m(...args)` succeeds without setting the invalid flag against chain
-state `b₁`, then on any extension `b₂ ⊇ b₁` the same call must succeed and
-return the same value.
+`computer.m(...args)` succeeds without invalidation against chain state `b₁`,
+then on any extension `b₂ ⊇ b₁` the same call must succeed and return the same
+value.
## Confirmed locations
@@ -43,38 +47,41 @@ call may succeed:
## Invalidation flow
-1. Query fails or observes a transient fact.
-2. InnerComputer marks the **current evaluation-stack frame** invalid and
- throws. Frames are push/pop around each `Db.eval` and `Modules.load` so
- concurrent async work cannot cross-talk. (Free-var `computer` may be a
- create/module instance different from the eval endowment; the stack still
- records invalidation for the active evaluation.)
-3. Compartment returns (possibly after `catch` — the frame flag is **not**
- cleared until the frame is popped after the invalidity check).
-4. `Db.eval` / `Modules.load` read the frame (via `computer.isInvalid`) and
- reject with a single public error string.
+1. Query fails or observes a transient fact (or a policy rule rejects, e.g.
+ future height).
+2. InnerComputer marks the **current evaluation frame** invalid and throws.
+ Each `Db.eval` / `Modules.load` runs under `withEvalInvalidation`:
+ - **Node:** `AsyncLocalStorage` (no static `node:async_hooks` import).
+ - **Browser:** await-scoped stack with serialized root frames (no Promise
+ patching under SES `lockdown`). Nested loads nest; concurrent roots queue.
+3. Free-var `computer` may be a create/module instance different from the eval
+ endowment; invalidation still hits the **active frame**.
+4. Compartment may return after `catch` — the frame flag is **not** cleared
+ until the host checks it.
+5. Host checks **`frame.invalid` / `frame.msg`** (not only `computer.isInvalid`)
+ on both throw and catch-and-continue paths.
+6. In-compartment `computer` is a **hardened method facade**.
Error text always ends with exactly one copy of:
> Accessing non-existent on-chain state inside a smart contract is forbidden.
-Context from the failing query (or a short policy reason such as “future
-height”) is included at most once before that suffix. Catch-and-continue and
-uncaught paths do **not** double the forbidden sentence.
+A short reason may appear before that suffix. Policy rejects and missing
+observations share the same single-suffix form (never a short reason alone,
+never a doubled forbidden sentence). Match with `message.endsWith(...)`.
## Client vs contract `computer`
-The outer [Computer](/docs/reference/computer-class) client may return
-`undefined` for unconfirmed data and supports writes (`new`, `broadcast`, …).
-The in-contract global is a different, stricter surface. Full method tables and
-examples live in the library Contract reference (Retype docs:
-`Lib/Contract` — Querying inside of a Contract).
+The outer Computer client may return `undefined` for unconfirmed data and
+supports writes (`new`, `broadcast`, …). The in-contract global is a different,
+stricter surface. Full method tables: `packages/docs/Lib/Contract/`.
## Practical implications
- Confirm deploys before `load` in contracts.
- Confirm object revisions before history walks or escrow audits.
- For terminal `last` checks, spend the tip and wait for confirmation.
-- Stabilize in-contract TXO queries with a historical height or block hash.
+- Stabilize in-contract TXO queries with a historical height or block hash;
+ empty results with a valid stabilizer are allowed.
- Escrow/chess apps: cancel or settle, wait for confirmation, then
`withdraw` / refund.
diff --git a/packages/docs-2/docs/concepts/how-it-works.md b/packages/docs-2/docs/concepts/how-it-works.md
index a79fc60ae..92ca45d66 100644
--- a/packages/docs-2/docs/concepts/how-it-works.md
+++ b/packages/docs-2/docs/concepts/how-it-works.md
@@ -179,16 +179,18 @@ blockchain operations and provides full IDE support.
Inside contract methods the `computer` global exposes only deterministic,
read-only on-chain queries. Any failure or transient observation (missing
revision, unconfirmed location, no next successor yet, unspent tip for
- `last`, unguarded/future `getTXOs`, RPC error, etc.) raises an internal
- invalidation flag (`globalInvalidState` in `inner-computer.ts`). After the
- secure compartment returns, `Db.eval` inspects the flag and rejects the
- entire transition if it was set—even when the contract caught the thrown
- error. The public error message ends with a single standard suffix
- (“Accessing non-existent on-chain state inside a smart contract is
- forbidden.”). Controlled mutations required for
- reconstruction and metadata attachment are performed under an explicit
- privilege guard (`_sudo` / `AdminContext` in `admin.ts`) that restores the
- normal security invariants afterward.
+ `last`, unguarded/future `getTXOs`, RPC error, etc.) marks the current
+ **evaluation frame** invalid (`withEvalInvalidation`: ALS on Node; stack +
+ serialized roots in the browser). When the compartment returns or throws,
+ the host rejects if that frame is invalid—even when the contract caught the
+ thrown error—using the frame itself (not only `computer.isInvalid`). Public
+ errors always end with a single standard suffix (“Accessing non-existent
+ on-chain state inside a smart contract is forbidden.”), optionally preceded
+ by a short reason. The compartment endowment is a hardened facade of public
+ methods. Controlled mutations required for reconstruction and metadata
+ attachment are performed under an explicit privilege guard (`_sudo` /
+ `AdminContext` in `admin.ts`) that restores the normal security invariants
+ afterward.
[^7]:
The `SmartContract` type is produced by a covariant recursive lifting
diff --git a/packages/docs-2/docs/intro.md b/packages/docs-2/docs/intro.md
index c8e0f0998..279e7e3de 100644
--- a/packages/docs-2/docs/intro.md
+++ b/packages/docs-2/docs/intro.md
@@ -230,10 +230,11 @@ methods is a restricted `InnerComputer`. It only exposes safe read operations
block/time helpers, guarded `getTXOs`, etc.). It cannot create or broadcast
transactions, and it does **not** expose `latest`. Reads generally require
**confirmed** locations; missing, mempool-only, or other transient observations
-set an invalidation flag and reject the entire evaluation even if the contract
-catches the thrown error. Rejected evaluations surface an error that ends with
-a single standard phrase: “Accessing non-existent on-chain state inside a smart
-contract is forbidden.”
+mark the current **evaluation frame** invalid and reject the entire evaluation
+even if the contract catches the thrown error. Contracts cannot clear or hide invalidation. Rejected evaluations surface an
+error that ends with a single standard phrase: “Accessing non-existent on-chain
+state inside a smart contract is forbidden.” (A short reason may appear before
+that suffix; never the short reason alone.)
### Low-Level Control for Complex Protocols
diff --git a/packages/docs/Lib/Contract/index.md b/packages/docs/Lib/Contract/index.md
index 709789357..7f94f626d 100644
--- a/packages/docs/Lib/Contract/index.md
+++ b/packages/docs/Lib/Contract/index.md
@@ -76,6 +76,8 @@ expect(c.n).eq(0)
Smart contracts can access a restricted global `computer` (the **InnerComputer**) to read on-chain history and block metadata. These helpers do not write blockchain state. They exist so contracts can traverse revision graphs, load modules, or base decisions on confirmed chain data in a **deterministic** way.
+Architecture (SES sandbox, eval-frame invalidation, hardened endowment, client vs contract `computer`) is documented in **[Sandbox & Inner Computer](./sandbox-and-inner-computer.md)**.
+
The outer [`Computer`](../Computer/index.md) client API is **not** the same surface: many client methods may return `undefined` for mempool data or live tips. Inside a contract, almost every “not yet known / not yet confirmed” observation **invalidates** the whole evaluation so two validators can never disagree under chain extension.
### Determinism property (observation stability)
@@ -90,9 +92,12 @@ Successful observations must therefore be **invariant under future chain growth*
### How invalidation works
-1. On a forbidden observation, InnerComputer marks the **current evaluation-stack frame** invalid and throws. Frames are pushed/popped around each `Db.eval` evaluate and each `Modules.load` import (not a process-wide singleton), so concurrent evaluations cannot cross-talk.
-2. Free-variable `computer` in methods may be the create-time or module-load instance (SES lexical binding), different from the eval endowment. Invalidation still applies to the **active frame**, so catch-and-continue cannot soft-succeed.
-3. A contract `try/catch` **cannot** clear the flag (reset requires admin privilege). After the compartment returns, `Db.eval` rejects the transition if the active frame is invalid.
+1. On a forbidden observation, InnerComputer marks the **current evaluation frame** invalid and throws. Each `Db.eval` / `Modules.load` binds a frame via `withEvalInvalidation`:
+ - **Node:** `AsyncLocalStorage` (loaded without a static `node:async_hooks` import so browser bundles stay clean). Concurrent evals are isolated by async context.
+ - **Browser:** await-scoped stack with serialized roots (no Promise patching under SES `lockdown`). Nested loads still nest; concurrent root evals queue.
+2. Free-variable `computer` in methods may be the create-time or module-load instance (SES lexical binding), different from the eval endowment. Invalidation still applies to the **active eval frame**, so catch-and-continue cannot soft-succeed.
+3. A contract `try/catch` **cannot** clear the flag (reset requires admin privilege). After the compartment returns **or** throws, the host rejects using the **frame object** it holds (`frame.invalid` / `frame.msg`), not by trusting `computer.isInvalid` alone.
+4. The compartment is endowed with a **hardened facade** of public InnerComputer methods (no internal `Computer` client, methods not replaceable). `resetInvalid` remains admin-only.
#### Error message shape
@@ -100,10 +105,10 @@ All invalidation errors exposed to callers end with **exactly one** copy of:
> Accessing non-existent on-chain state inside a smart contract is forbidden.
-- Direct policy rejections (for example, future `getBlockHash` height, missing `getTXOs` stabilizer) store a short reason; when the contract catches and continues, `Db.eval` re-throws via a shared formatter so the standard suffix is still present **once**.
-- Uncaught throws and catch-and-continue paths therefore share the same single-suffix convention (no doubled “forbidden” text).
+- Policy rejections (for example, future `getBlockHash` height, missing `getTXOs` stabilizer) and missing/unconfirmed observations both go through a shared formatter as soon as invalidation fires. A short reason may appear **before** the standard suffix; the suffix is never doubled.
+- Uncaught throws and catch-and-continue paths share this shape: the thrown error and `frame.msg` already carry the single suffix; `Db.eval` rethrows the same canonical form when the frame is invalid.
-Clients and tests should match with `message.endsWith(...)` (or equivalent), not assume a doubled suffix.
+Clients and tests should match with `message.endsWith(...)` (or equivalent). Do not expect a short policy reason alone without the forbidden suffix.
### Confirmed locations only
@@ -117,8 +122,8 @@ Most location-based APIs require the referenced **transaction to be confirmed**
| `next` | Starting revision **and** returned successor must be confirmed |
| `last` | Starting revision, returned tip, and the tip’s **spending** tx must be confirmed (unspent tip or mempool-only spend → invalidate) |
| `txIdToBlockTime` | Tx must be confirmed (no nullish “not mined yet”) |
-| `txIdToBlockHeight` / `txIdToBlockHash` | Unconfirmed → invalidate (via throw / nullish fail-closed) |
-| `getTXOs` (+ `getUTXOs` / `getOTXOs` / `getOUTXOs`) | Must include a **stabilizing filter** (below); future heights forbidden |
+| `txIdToBlockHeight` / `txIdToBlockHash` | Unconfirmed → invalidate (no block hash / not mined yet) |
+| `getTXOs` (+ `getUTXOs` / `getOTXOs` / `getOUTXOs`) | Must include a **stabilizing filter** (below); future/negative heights forbidden |
**App / test implication:** after `deploy`, `new`, method calls, or `delete`, wait for confirmation before on-chain code that walks history, loads modules, or calls `last` / `next` / `txIdToBlockTime` on those locations.
@@ -210,15 +215,16 @@ Inside a contract the query **must** include one stabilizing filter:
- `blockHeight` — must be ≤ current tip
- `blockHash` — fixed historical block
-Queries without a stabilizer, or with a future height, invalidate. Empty result sets with a valid stabilizer are fine.
+Queries without a stabilizer, or with a future/negative height, invalidate. Empty result sets with a valid stabilizer are fine (indexing lag is an application concern, not invalidation).
### Usage notes & best practices
1. **Confirm before query.** Deploy modules, create objects, update or delete tips, then wait for confirmation before contract methods that call InnerComputer on those locations.
2. **Prefer `prev` / `getAncestors` / `first` for history walks.** Use `next` only when a confirmed successor must exist (e.g. deposit pre/post pair).
3. **Do not treat `last` as “latest live tip”.** For terminal claims, spend the tip (e.g. `delete`) and wait for confirmation, then call `last`.
-4. **`try/catch` does not soft-fail invalidation.** Catching the throw still rejects the transition if the invalid flag was set. The public error still ends with a **single** “Accessing non-existent…” suffix (whether the throw was uncaught or re-raised by `Db.eval`).
+4. **`try/catch` does not soft-fail invalidation.** Catching the throw still rejects the transition if the evaluation frame is invalid. Public errors always end with a **single** “Accessing non-existent…” suffix (policy reasons and missing locations alike).
5. **Stabilize TXO queries** with `lteBlockHeight`, `blockHeight`, or `blockHash`.
6. **Off-chain code** using the outer `Computer` may still see mempool data; only the in-contract `computer` global enforces these rules.
+7. **Escrow / multi-step apps:** after cancel, settle, or `delete`, wait for confirmation before a follow-up contract call that depends on `last`, deposit deltas via `next`, or confirmed history (see [Sandbox & Inner Computer – Practical implications](./sandbox-and-inner-computer.md#practical-implications)).
This API, together with `Contract` property rules, enables verifiable on-chain logic while keeping evaluations fail-closed under non-deterministic observations.
diff --git a/packages/docs/Lib/Contract/sandbox-and-inner-computer.md b/packages/docs/Lib/Contract/sandbox-and-inner-computer.md
new file mode 100644
index 000000000..f10875adb
--- /dev/null
+++ b/packages/docs/Lib/Contract/sandbox-and-inner-computer.md
@@ -0,0 +1,78 @@
+---
+icon: shield
+---
+
+# Sandbox & Inner Computer
+
+Contract methods run inside a restricted **SES compartment**. The only chain-facing API available to that code is the **InnerComputer** (`computer` global): a read-only, fail-closed view of **confirmed** blockchain state.
+
+For the full method reference, stabilizers, and confirmation rules, see [Querying inside of a Contract](./index.md#querying-inside-of-a-contract).
+
+## Goals
+
+1. **Determinism** — If a query succeeds against a chain prefix, the same call must succeed with the same result on every extension of that chain.
+2. **Fail closed** — Transient facts (mempool, “not yet”, future heights) never become part of a valid transition.
+3. **No silent soft-fail** — Catching a thrown error does not clear invalidation; after the compartment returns, the host still rejects the transition if the evaluation was marked invalid.
+
+## Observation stability
+
+For any InnerComputer method `m` and arguments `args`: if `computer.m(...args)` succeeds without invalidation against chain state `b₁`, then on any extension `b₂ ⊇ b₁` the same call must succeed and return the same value.
+
+Equivalently: every successful observation is **invariant under future chain growth**.
+
+## Confirmed locations (summary)
+
+Most APIs require the referenced transaction to be **in a block** before the call may succeed:
+
+| Family | Rule (summary) |
+| ------------------------------------------- | ------------------------------------------------------------------------------------ |
+| `sync` / `decode` / `load` / `getAncestors` | Start location/tx confirmed |
+| `first` / `prev` | Start rev confirmed; `prev` may return `undefined` at root |
+| `next` | Start **and** returned successor confirmed; no next → invalidate |
+| `last` | Start and result confirmed; tip must be **spent confirmed** (not a live unspent tip) |
+| Block time/height/hash of a tx | Tx confirmed |
+| `getBlockHash(height)` | Height ≤ tip; not future |
+| `getTXOs` (+ aliases) | Stabilizer: `lteBlockHeight` / `blockHeight` / `blockHash` |
+
+`latest` is **not** exposed inside contracts (the live tip is non-deterministic under chain extension).
+
+### Error message shape
+
+Every invalidation path builds the public string with a shared formatter so callers always see **exactly one** copy of:
+
+> Accessing non-existent on-chain state inside a smart contract is forbidden.
+
+A short reason may appear before that suffix, for example:
+
+- `getBlockHash with future height is forbidden. Accessing non-existent on-chain state inside a smart contract is forbidden.`
+- `Transaction id … not found or not yet confirmed by sync. Accessing non-existent on-chain state inside a smart contract is forbidden.`
+
+## Client vs contract `computer`
+
+| | Outer [`Computer`](../Computer/index.md) | InnerComputer (`computer` in contracts) |
+| ---------------------------------------- | ---------------------------------------- | --------------------------------------------------------------- |
+| Writes (`new`, `broadcast`, …) | Yes | No |
+| Mempool / unconfirmed reads | Often allowed (may return `undefined`) | Forbidden → invalidate |
+| `latest` | Yes | **Not exposed** |
+| `getTXOs` without height/hash stabilizer | Yes | Forbidden → invalidate |
+| Concurrent evaluations | Multiple clients/calls | Isolated eval frames (ALS on Node; serialized roots in browser) |
+
+## Security notes (what contracts cannot do)
+
+- Contracts cannot create or clear eval frames.
+- The endowment is hardened so contracts cannot replace `sync` / `first` / …, redefine internal functions, or reassign the prototype to hide invalidation.
+- Host reject decisions use the **frame**, not only endowment getters.
+
+## Practical implications
+
+- Confirm module deploys before contract `load`.
+- Confirm object revisions before history walks or escrow audits.
+- For terminal `last` checks, spend the tip (e.g. `delete`) and wait for confirmation.
+- Stabilize in-contract TXO queries with a historical height or block hash; empty result sets with a valid stabilizer are fine (wait for indexing if apps/tests expect a known object to appear).
+- Escrow / chess flows: cancel or settle, **wait for confirmation**, then `withdraw` / refund (cancel and withdraw cannot be one atomic observation of unconfirmed tip spend).
+
+## See also
+
+- [Contract – Querying API](./index.md#querying-inside-of-a-contract)
+- [Computer API](../Computer/index.md)
+- [How it Works](../../how-it-works.md)
diff --git a/packages/docs/Lib/index.md b/packages/docs/Lib/index.md
index 5fbda7ffa..8ae8c943e 100644
--- a/packages/docs/Lib/index.md
+++ b/packages/docs/Lib/index.md
@@ -13,5 +13,7 @@ The Bitcoin Computer Library has the exports below.
|-----------------------|-----------------------------------------|
| [Computer](./Computer/) | Read and write smart contracts |
| [Transaction](./Transaction/) | Parse Bitcoin Computer transactions |
-| [Contract](./Contract//) | Extend from this class to create smart contracts |
+| [Contract](./Contract/) | Extend from this class to create smart contracts |
+| [Contract – Querying / InnerComputer](./Contract/index.md#querying-inside-of-a-contract) | Deterministic on-chain reads inside methods |
+| [Sandbox & Inner Computer](./Contract/sandbox-and-inner-computer.md) | SES sandbox, eval-frame invalidation, hardened endowment |
| [Mock](./Mock/) | Mock on-chain objects |
diff --git a/packages/docs/how-it-works.md b/packages/docs/how-it-works.md
index 214c3c480..513b16bc8 100644
--- a/packages/docs/how-it-works.md
+++ b/packages/docs/how-it-works.md
@@ -44,6 +44,14 @@ We call a string of the form `id:num` where `id` is a transaction id and `num` i
- `sync` maps a revision to a value.
- `encode` maps an expression and a blockchain environment to a transaction.
+## The Inner Computer
+
+Outside the protocol, the library client can create, fund, and broadcast transactions and may read mempool state. **Inside** a smart-contract method, evaluation runs in a restricted sandbox. The only chain-facing API is a global `computer` object (**InnerComputer**): contracts can load other objects, walk revision history, and read confirmed block times, but they cannot write or broadcast.
+
+Those reads must be deterministic. Successful observations have to stay valid as the chain grows; unstable results invalidate the whole evaluation so every honest validator replaying the method gets the same outcome. A `try/catch` inside the contract cannot soft-succeed. This enables patterns such as escrows whose conditions depend on other contracts or on confirmed time.
+
+The full API, confirmation rules, error shape, and sandbox model are documented under [Contract – Querying](./Lib/Contract/index.md#querying-inside-of-a-contract) and [Sandbox & Inner Computer](./Lib/Contract/sandbox-and-inner-computer.md).
+
## Provenance
If the value returned from _sync_ contains an object, it has extra properties _\_id_, _\_rev_, _\_root_ that specify its location on the blockchain and its provenance.
diff --git a/packages/docs/language.md b/packages/docs/language.md
index c63c6e8be..668d200ce 100644
--- a/packages/docs/language.md
+++ b/packages/docs/language.md
@@ -11,6 +11,8 @@ In order to make it possible to write smart contracts in JavaScript, the Bitcoin
We call an expression a _smart contract_ if it returns a value that contains only sub-objects whose classes that extend from `Contract`. The function `encode` throws an error if it called with an expression that is not a smart contract. Likewise, the function `sync` throws an error if it is called with a transaction whose expression is not a smart contract.
+Inside contract methods, a restricted global `computer` (**InnerComputer**) provides deterministic, confirmed-only chain reads. Transient observations invalidate the evaluation (a `try/catch` cannot soft-succeed); public errors end with a single standard forbidden suffix. See [Contract – Querying](./Lib/Contract/index.md#querying-inside-of-a-contract) and [Sandbox & Inner Computer](./Lib/Contract/sandbox-and-inner-computer.md).
+
To describe the behavior of `Contract` more precisely, let `obj` be an object of a class that extends from `Contract`. Then an error is thrown if either
1. a property of `obj` is assigned outside of a method of `obj`,
From 4d37b7a72cee4e420ac8cbd78419c841bfe6428c Mon Sep 17 00:00:00 2001
From: ltardivo
Date: Thu, 6 Aug 2026 17:10:57 -0700
Subject: [PATCH 5/6] fix(lib): endow console only in dev/debug; simplify eval
host path
Build SES compartment globals via buildContractCompartmentGlobals so host
console is available only in client mode dev/debug. In prod, console is not in scope (contracts must not rely on it).
Simplify without changing security semantics:
- Shared globals helper for Db.eval and Modules.load
- Single invalidation exit in Db.eval (capture frame.msg before resetInvalid)
- Same host pattern for Modules.load import failures
- Drop redundant formatInvalidStateError in _safeCall
- Slim stack-frame pop in withEvalInvalidation
(docs): no console in prod contracts; refresh sandbox notes
Document that compartment host console is endowed only in dev/debug client
mode and must not be used in production smart contracts (ReferenceError in
prod). Update sandbox-and-inner-computer, Contract querying, how-it-works,
language, and docs-2 architecture accordingly.
---
.../sandbox-and-inner-computer.md | 3 ++
packages/docs/Lib/Contract/index.md | 1 +
.../Contract/sandbox-and-inner-computer.md | 43 +++++++++++++++----
packages/docs/how-it-works.md | 2 +-
packages/docs/language.md | 2 +-
5 files changed, 40 insertions(+), 11 deletions(-)
diff --git a/packages/docs-2/docs/architecture/sandbox-and-inner-computer.md b/packages/docs-2/docs/architecture/sandbox-and-inner-computer.md
index 19e261ba3..4a0ffc2b0 100644
--- a/packages/docs-2/docs/architecture/sandbox-and-inner-computer.md
+++ b/packages/docs-2/docs/architecture/sandbox-and-inner-computer.md
@@ -61,6 +61,9 @@ call may succeed:
5. Host checks **`frame.invalid` / `frame.msg`** (not only `computer.isInvalid`)
on both throw and catch-and-continue paths.
6. In-compartment `computer` is a **hardened method facade**.
+7. Host `console` is endowed only in client `dev` / `debug` mode. In **`prod`**,
+ contracts must not use `console` (not in scope). Logging is not part of the
+ on-chain API.
Error text always ends with exactly one copy of:
diff --git a/packages/docs/Lib/Contract/index.md b/packages/docs/Lib/Contract/index.md
index 7f94f626d..6c0180e16 100644
--- a/packages/docs/Lib/Contract/index.md
+++ b/packages/docs/Lib/Contract/index.md
@@ -98,6 +98,7 @@ Successful observations must therefore be **invariant under future chain growth*
2. Free-variable `computer` in methods may be the create-time or module-load instance (SES lexical binding), different from the eval endowment. Invalidation still applies to the **active eval frame**, so catch-and-continue cannot soft-succeed.
3. A contract `try/catch` **cannot** clear the flag (reset requires admin privilege). After the compartment returns **or** throws, the host rejects using the **frame object** it holds (`frame.invalid` / `frame.msg`), not by trusting `computer.isInvalid` alone.
4. The compartment is endowed with a **hardened facade** of public InnerComputer methods (no internal `Computer` client, methods not replaceable). `resetInvalid` remains admin-only.
+5. **`console` is only available in `dev` / `debug` mode.** In **`prod`**, contracts must not use `console` (it is not in scope → `ReferenceError`). Logging is not part of the deterministic on-chain API; see [Sandbox & Inner Computer](./sandbox-and-inner-computer.md#console-endowment-dev-only).
#### Error message shape
diff --git a/packages/docs/Lib/Contract/sandbox-and-inner-computer.md b/packages/docs/Lib/Contract/sandbox-and-inner-computer.md
index f10875adb..948743e47 100644
--- a/packages/docs/Lib/Contract/sandbox-and-inner-computer.md
+++ b/packages/docs/Lib/Contract/sandbox-and-inner-computer.md
@@ -36,9 +36,28 @@ Most APIs require the referenced transaction to be **in a block** before the cal
`latest` is **not** exposed inside contracts (the live tip is non-deterministic under chain extension).
+## Invalidation flow
+
+1. A query fails or observes a transient fact (or a direct policy rule rejects, e.g. future height).
+2. InnerComputer marks the **current evaluation frame** invalid and throws. Each `Db.eval` / `Modules.load` runs under `withEvalInvalidation`:
+ - **Node:** `AsyncLocalStorage` via `process.getBuiltinModule('async_hooks')` (no static `node:async_hooks` import, so browser bundles stay clean). Concurrent evaluations are truly concurrent and isolated by async context.
+ - **Browser:** await-scoped stack with **serialized root** frames (no Promise patching under SES `lockdown`). Nested frames (e.g. `Modules.load` inside `Db.eval`) still nest; concurrent root evals queue so stack tops never cross-talk.
+3. Free-variable `computer` in methods may resolve to the **create-time or module-load** instance (SES lexical binding), which can differ from the eval endowment. Invalidation still applies to the **active eval frame**.
+4. The compartment may return after `catch` — the frame flag is **not** cleared until the host has checked it.
+5. The host accepts or rejects using **`frame.invalid` / `frame.msg`** (not only `computer.isInvalid`), so shadowing getters on the endowment cannot hide invalidation. If the compartment throws *or* returns after catch-and-continue, `Db.eval` still rejects when the frame is marked invalid.
+6. The in-compartment `computer` is a **hardened method facade**: public query methods only, no internal client object, methods not replaceable, `resetInvalid` is admin-only.
+
+### `console` endowment (dev only)
+
+Compartment globals include host `console` only when the client `mode` is **`dev`** or **`debug`**.
+
+In **`prod`**, `console` is **not** endowed. Contract or module code that references `console` gets a `ReferenceError` and the evaluation fails. **Do not use `console` in production smart contracts** — logging is not part of the on-chain API, and endowing the shared host `console` is ambient authority (especially without SES `lockdown`, which runs in prod).
+
+Use off-chain tooling and the outer `Computer` client for diagnostics.
+
### Error message shape
-Every invalidation path builds the public string with a shared formatter so callers always see **exactly one** copy of:
+Every invalidation path (policy `_invalidate`, missing/nullish `_safeCall`, and host rethrow from `Db.eval`) builds the public string with a shared formatter so callers always see **exactly one** copy of:
> Accessing non-existent on-chain state inside a smart contract is forbidden.
@@ -47,21 +66,26 @@ A short reason may appear before that suffix, for example:
- `getBlockHash with future height is forbidden. Accessing non-existent on-chain state inside a smart contract is forbidden.`
- `Transaction id … not found or not yet confirmed by sync. Accessing non-existent on-chain state inside a smart contract is forbidden.`
+The formatter is idempotent (already-suffixed strings are not doubled). Match with `message.endsWith(...)` (or equivalent). Do **not** expect a short policy reason alone without the standard suffix.
+
## Client vs contract `computer`
-| | Outer [`Computer`](../Computer/index.md) | InnerComputer (`computer` in contracts) |
-| ---------------------------------------- | ---------------------------------------- | --------------------------------------------------------------- |
-| Writes (`new`, `broadcast`, …) | Yes | No |
-| Mempool / unconfirmed reads | Often allowed (may return `undefined`) | Forbidden → invalidate |
-| `latest` | Yes | **Not exposed** |
-| `getTXOs` without height/hash stabilizer | Yes | Forbidden → invalidate |
+| | Outer [`Computer`](../Computer/index.md) | InnerComputer (`computer` in contracts) |
+| ---------------------------------------- | ---------------------------------------- | --------------------------------------- |
+| Writes (`new`, `broadcast`, …) | Yes | No |
+| Mempool / unconfirmed reads | Often allowed (may return `undefined`) | Forbidden → invalidate |
+| `latest` | Yes | **Not exposed** |
+| `getTXOs` without height/hash stabilizer | Yes | Forbidden → invalidate |
+| Host `console` in compartment | N/A | **`dev` / `debug` only** (not in `prod`) |
| Concurrent evaluations | Multiple clients/calls | Isolated eval frames (ALS on Node; serialized roots in browser) |
## Security notes (what contracts cannot do)
-- Contracts cannot create or clear eval frames.
-- The endowment is hardened so contracts cannot replace `sync` / `first` / …, redefine internal functions, or reassign the prototype to hide invalidation.
+- Contracts cannot create or clear eval frames (`withEvalInvalidation` is host-only).
+- `computer.resetInvalid()` is a no-op without admin privilege; `constructor.resetGlobalInvalid()` is a no-op for contracts.
+- The endowment is hardened so contracts cannot replace `sync` / `first` / …, redefine `isInvalid`, or reassign the prototype to hide invalidation.
- Host reject decisions use the **frame**, not only endowment getters.
+- In **`prod`**, contracts cannot use `console` (not endowed). Prefer no logging in on-chain code at all.
## Practical implications
@@ -70,6 +94,7 @@ A short reason may appear before that suffix, for example:
- For terminal `last` checks, spend the tip (e.g. `delete`) and wait for confirmation.
- Stabilize in-contract TXO queries with a historical height or block hash; empty result sets with a valid stabilizer are fine (wait for indexing if apps/tests expect a known object to appear).
- Escrow / chess flows: cancel or settle, **wait for confirmation**, then `withdraw` / refund (cancel and withdraw cannot be one atomic observation of unconfirmed tip spend).
+- Do not ship contract methods that call `console.*` if they must run under `mode: 'prod'`.
## See also
diff --git a/packages/docs/how-it-works.md b/packages/docs/how-it-works.md
index 513b16bc8..c2faff9df 100644
--- a/packages/docs/how-it-works.md
+++ b/packages/docs/how-it-works.md
@@ -48,7 +48,7 @@ We call a string of the form `id:num` where `id` is a transaction id and `num` i
Outside the protocol, the library client can create, fund, and broadcast transactions and may read mempool state. **Inside** a smart-contract method, evaluation runs in a restricted sandbox. The only chain-facing API is a global `computer` object (**InnerComputer**): contracts can load other objects, walk revision history, and read confirmed block times, but they cannot write or broadcast.
-Those reads must be deterministic. Successful observations have to stay valid as the chain grows; unstable results invalidate the whole evaluation so every honest validator replaying the method gets the same outcome. A `try/catch` inside the contract cannot soft-succeed. This enables patterns such as escrows whose conditions depend on other contracts or on confirmed time.
+Those reads must be deterministic. Successful observations have to stay valid as the chain grows; unstable results invalidate the whole evaluation so every honest validator replaying the method gets the same outcome. A `try/catch` inside the contract cannot soft-succeed. Host `console` is available inside contracts only in `dev` / `debug` client mode — **not in `prod`**. This enables patterns such as escrows whose conditions depend on other contracts or on confirmed time.
The full API, confirmation rules, error shape, and sandbox model are documented under [Contract – Querying](./Lib/Contract/index.md#querying-inside-of-a-contract) and [Sandbox & Inner Computer](./Lib/Contract/sandbox-and-inner-computer.md).
diff --git a/packages/docs/language.md b/packages/docs/language.md
index 668d200ce..5ba975c26 100644
--- a/packages/docs/language.md
+++ b/packages/docs/language.md
@@ -11,7 +11,7 @@ In order to make it possible to write smart contracts in JavaScript, the Bitcoin
We call an expression a _smart contract_ if it returns a value that contains only sub-objects whose classes that extend from `Contract`. The function `encode` throws an error if it called with an expression that is not a smart contract. Likewise, the function `sync` throws an error if it is called with a transaction whose expression is not a smart contract.
-Inside contract methods, a restricted global `computer` (**InnerComputer**) provides deterministic, confirmed-only chain reads. Transient observations invalidate the evaluation (a `try/catch` cannot soft-succeed); public errors end with a single standard forbidden suffix. See [Contract – Querying](./Lib/Contract/index.md#querying-inside-of-a-contract) and [Sandbox & Inner Computer](./Lib/Contract/sandbox-and-inner-computer.md).
+Inside contract methods, a restricted global `computer` (**InnerComputer**) provides deterministic, confirmed-only chain reads. Transient observations invalidate the evaluation (a `try/catch` cannot soft-succeed); public errors end with a single standard forbidden suffix. Host `console` is available only in `dev` / `debug` client mode — **not in `prod`** (do not rely on `console` in production contracts). See [Contract – Querying](./Lib/Contract/index.md#querying-inside-of-a-contract) and [Sandbox & Inner Computer](./Lib/Contract/sandbox-and-inner-computer.md).
To describe the behavior of `Contract` more precisely, let `obj` be an object of a class that extends from `Contract`. Then an error is thrown if either
From 699ffaf4abd59eb644813e563a009696c8bd5b86 Mon Sep 17 00:00:00 2001
From: Clemens Ley
Date: Sat, 8 Aug 2026 18:22:50 -0700
Subject: [PATCH 6/6] docs: align InnerComputer sandbox docs with frame-only
invalidation
Document query-only endowment, host frame authority, and free-var routing
to the active eval client. Drop obsolete isInvalid / resetInvalid contract APIs.
---
.../architecture/sandbox-and-inner-computer.md | 15 +++++++++------
packages/docs-2/docs/concepts/how-it-works.md | 14 ++++++++------
packages/docs/Lib/Contract/index.md | 8 ++++----
.../Lib/Contract/sandbox-and-inner-computer.md | 14 +++++++-------
4 files changed, 28 insertions(+), 23 deletions(-)
diff --git a/packages/docs-2/docs/architecture/sandbox-and-inner-computer.md b/packages/docs-2/docs/architecture/sandbox-and-inner-computer.md
index 4a0ffc2b0..22069bdb4 100644
--- a/packages/docs-2/docs/architecture/sandbox-and-inner-computer.md
+++ b/packages/docs-2/docs/architecture/sandbox-and-inner-computer.md
@@ -50,17 +50,20 @@ call may succeed:
1. Query fails or observes a transient fact (or a policy rule rejects, e.g.
future height).
2. InnerComputer marks the **current evaluation frame** invalid and throws.
- Each `Db.eval` / `Modules.load` runs under `withEvalInvalidation`:
+ Invalidation is **frame-only** (no per-instance flags; no contract-facing
+ invalidation API). Each `Db.eval` / `Modules.load` runs under
+ `withEvalInvalidation` (dedicated eval-frame module):
- **Node:** `AsyncLocalStorage` (no static `node:async_hooks` import).
- **Browser:** await-scoped stack with serialized root frames (no Promise
patching under SES `lockdown`). Nested loads nest; concurrent roots queue.
-3. Free-var `computer` may be a create/module instance different from the eval
- endowment; invalidation still hits the **active frame**.
+3. Host installs the active observation client on the frame; free-var query
+ methods route to that client. Invalidation always hits the **active frame**.
4. Compartment may return after `catch` — the frame flag is **not** cleared
until the host checks it.
-5. Host checks **`frame.invalid` / `frame.msg`** (not only `computer.isInvalid`)
- on both throw and catch-and-continue paths.
-6. In-compartment `computer` is a **hardened method facade**.
+5. Host checks **only `frame.invalid` / `frame.msg`** on both throw and
+ catch-and-continue paths.
+6. In-compartment `computer` is a **hardened query-only facade** (no
+ `isInvalid` / `resetInvalid`).
7. Host `console` is endowed only in client `dev` / `debug` mode. In **`prod`**,
contracts must not use `console` (not in scope). Logging is not part of the
on-chain API.
diff --git a/packages/docs-2/docs/concepts/how-it-works.md b/packages/docs-2/docs/concepts/how-it-works.md
index 92ca45d66..ccd4cd983 100644
--- a/packages/docs-2/docs/concepts/how-it-works.md
+++ b/packages/docs-2/docs/concepts/how-it-works.md
@@ -181,12 +181,14 @@ blockchain operations and provides full IDE support.
revision, unconfirmed location, no next successor yet, unspent tip for
`last`, unguarded/future `getTXOs`, RPC error, etc.) marks the current
**evaluation frame** invalid (`withEvalInvalidation`: ALS on Node; stack +
- serialized roots in the browser). When the compartment returns or throws,
- the host rejects if that frame is invalid—even when the contract caught the
- thrown error—using the frame itself (not only `computer.isInvalid`). Public
- errors always end with a single standard suffix (“Accessing non-existent
- on-chain state inside a smart contract is forbidden.”), optionally preceded
- by a short reason. The compartment endowment is a hardened facade of public
+ serialized roots in the browser). Invalidation is frame-only—there is no
+ per-instance flag and no contract-facing invalidation API. When the
+ compartment returns or throws, the host rejects if that frame is
+ invalid—even when the contract caught the thrown error—using only
+ `frame.invalid` / `frame.msg`. Public errors always end with a single
+ standard suffix (“Accessing non-existent on-chain state inside a smart
+ contract is forbidden.”), optionally preceded by a short reason. The
+ compartment endowment is a hardened **query-only** facade of public
methods. Controlled mutations required for reconstruction and metadata
attachment are performed under an explicit privilege guard (`_sudo` /
`AdminContext` in `admin.ts`) that restores the normal security invariants
diff --git a/packages/docs/Lib/Contract/index.md b/packages/docs/Lib/Contract/index.md
index 6c0180e16..51509fcd6 100644
--- a/packages/docs/Lib/Contract/index.md
+++ b/packages/docs/Lib/Contract/index.md
@@ -92,12 +92,12 @@ Successful observations must therefore be **invariant under future chain growth*
### How invalidation works
-1. On a forbidden observation, InnerComputer marks the **current evaluation frame** invalid and throws. Each `Db.eval` / `Modules.load` binds a frame via `withEvalInvalidation`:
+1. On a forbidden observation, InnerComputer marks the **current evaluation frame** invalid and throws. There is **no per-instance invalid flag**. Each `Db.eval` / `Modules.load` binds a frame via `withEvalInvalidation`:
- **Node:** `AsyncLocalStorage` (loaded without a static `node:async_hooks` import so browser bundles stay clean). Concurrent evals are isolated by async context.
- **Browser:** await-scoped stack with serialized roots (no Promise patching under SES `lockdown`). Nested loads still nest; concurrent root evals queue.
-2. Free-variable `computer` in methods may be the create-time or module-load instance (SES lexical binding), different from the eval endowment. Invalidation still applies to the **active eval frame**, so catch-and-continue cannot soft-succeed.
-3. A contract `try/catch` **cannot** clear the flag (reset requires admin privilege). After the compartment returns **or** throws, the host rejects using the **frame object** it holds (`frame.invalid` / `frame.msg`), not by trusting `computer.isInvalid` alone.
-4. The compartment is endowed with a **hardened facade** of public InnerComputer methods (no internal `Computer` client, methods not replaceable). `resetInvalid` remains admin-only.
+2. The host installs the active observation client on the frame; free-var query methods route to that client for the evaluation. Invalidation always writes the **active frame**, so catch-and-continue cannot soft-succeed.
+3. A contract `try/catch` **cannot** clear invalidation — the endowment has no invalidation API. After the compartment returns **or** throws, the host rejects using **only** the frame it holds (`frame.invalid` / `frame.msg`).
+4. The compartment is endowed with a **hardened query-only facade** of InnerComputer methods (no internal `Computer` client, no `isInvalid` / `resetInvalid`, methods not replaceable).
5. **`console` is only available in `dev` / `debug` mode.** In **`prod`**, contracts must not use `console` (it is not in scope → `ReferenceError`). Logging is not part of the deterministic on-chain API; see [Sandbox & Inner Computer](./sandbox-and-inner-computer.md#console-endowment-dev-only).
#### Error message shape
diff --git a/packages/docs/Lib/Contract/sandbox-and-inner-computer.md b/packages/docs/Lib/Contract/sandbox-and-inner-computer.md
index 948743e47..f2c00ba4a 100644
--- a/packages/docs/Lib/Contract/sandbox-and-inner-computer.md
+++ b/packages/docs/Lib/Contract/sandbox-and-inner-computer.md
@@ -39,13 +39,13 @@ Most APIs require the referenced transaction to be **in a block** before the cal
## Invalidation flow
1. A query fails or observes a transient fact (or a direct policy rule rejects, e.g. future height).
-2. InnerComputer marks the **current evaluation frame** invalid and throws. Each `Db.eval` / `Modules.load` runs under `withEvalInvalidation`:
+2. InnerComputer marks the **current evaluation frame** invalid and throws. There is **no per-instance invalid flag** and no contract-facing invalidation API. Each `Db.eval` / `Modules.load` runs under `withEvalInvalidation` (implementation lives in a dedicated eval-frame module):
- **Node:** `AsyncLocalStorage` via `process.getBuiltinModule('async_hooks')` (no static `node:async_hooks` import, so browser bundles stay clean). Concurrent evaluations are truly concurrent and isolated by async context.
- **Browser:** await-scoped stack with **serialized root** frames (no Promise patching under SES `lockdown`). Nested frames (e.g. `Modules.load` inside `Db.eval`) still nest; concurrent root evals queue so stack tops never cross-talk.
-3. Free-variable `computer` in methods may resolve to the **create-time or module-load** instance (SES lexical binding), which can differ from the eval endowment. Invalidation still applies to the **active eval frame**.
+3. The host sets the active observation client on the frame. Free-variable `computer` methods **route to that client** for the evaluation, so create-time SES bindings and the eval-time client share one observation identity. Invalidation always writes the **active frame**.
4. The compartment may return after `catch` — the frame flag is **not** cleared until the host has checked it.
-5. The host accepts or rejects using **`frame.invalid` / `frame.msg`** (not only `computer.isInvalid`), so shadowing getters on the endowment cannot hide invalidation. If the compartment throws *or* returns after catch-and-continue, `Db.eval` still rejects when the frame is marked invalid.
-6. The in-compartment `computer` is a **hardened method facade**: public query methods only, no internal client object, methods not replaceable, `resetInvalid` is admin-only.
+5. The host accepts or rejects using **only `frame.invalid` / `frame.msg`**. If the compartment throws *or* returns after catch-and-continue, `Db.eval` still rejects when the frame is marked invalid.
+6. The in-compartment `computer` is a **hardened query-only facade**: public observation methods only (no `isInvalid` / `errorMsg` / `resetInvalid`, no internal client object, methods not replaceable).
### `console` endowment (dev only)
@@ -82,9 +82,9 @@ The formatter is idempotent (already-suffixed strings are not doubled). Match wi
## Security notes (what contracts cannot do)
- Contracts cannot create or clear eval frames (`withEvalInvalidation` is host-only).
-- `computer.resetInvalid()` is a no-op without admin privilege; `constructor.resetGlobalInvalid()` is a no-op for contracts.
-- The endowment is hardened so contracts cannot replace `sync` / `first` / …, redefine `isInvalid`, or reassign the prototype to hide invalidation.
-- Host reject decisions use the **frame**, not only endowment getters.
+- The endowment exposes **query methods only** — there is no `isInvalid`, `errorMsg`, `resetInvalid`, or `resetGlobalInvalid` for contracts to call or shadow.
+- The endowment is hardened so contracts cannot replace `sync` / `first` / … or reassign the prototype to hide invalidation.
+- Host reject decisions use the **frame only**.
- In **`prod`**, contracts cannot use `console` (not endowed). Prefer no logging in on-chain code at all.
## Practical implications