# Audit report

> Review Submission23/ only (read Submission23/AUDIT_REQUEST.md first). World source 754ef44, NOT deployed; no real Celestao payment has ever been made. It adds a sixth GOLD product, and it is the first public copy of the Celestao payment overlay and of World's server side of that checkout, so no earlier review has read them.
>
> 1. Pay and receive: can a buyer pay and not receive, receive without paying, read another wallet's or run's report, or settle one product's order with another product's receipt?
> 2. Id mapping: can annual_ziwei and fortune-annual-ziwei disagree anywhere?
> 3. Migration 0023 on D1: can the table rebuild lose rows, break the foreign key from economy_orders, partly apply, or add the product at a wrong price or too early?
> 4. Signatures between World and Celestao: replay, wrong direction, wrong path, clock window.
> 5. Sessions and vault: cookie, CSRF, grant exchange, encryption binding, reads across wallets.
> 6. Input accepted at prepare that must fail after payment.
> 7. Script injection, or a wrong price or product shown, in web/world-wallet.js.
>
> Known items are in AUDIT_REQUEST.md section 3. Report severity, reproduction and fix. Not in scope: chart engines, report wording, 3D/rendering, media, AIMarket, Genesis.

| | |
|---|---|
| Repository | https://github.com/tungweb3/imd-ember-world-review.git |
| Commit | `adc60619636c8804ced0b9451a82091391015a19` |
| Job | `521bc017-e6c0-44fe-8470-c166995f7fec` |
| Judged | 2026-11-03 18:55 UTC |
| Findings | 1 high · 1 medium · 5 low · 4 info |

Four agents audited the code as it is at `adc6061`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. High: Wallet sign-in callback is refused by the overlay's own Sec-Fetch-Site guard, because a browser arriving from the World redirect always sends Sec-Fetch-Site: cross-site; no wallet session can be issue

`Submission23/source/integrations/celestao/celestao_world/app.py:71`

```
        if own and request.headers.get('sec-fetch-site') not in (None,'same-origin','none'):
```

The guards middleware applies the Sec-Fetch-Site rule to every path under /api/world/ and /api/holder/world/, including GET /api/holder/world/callback, the OAuth redirect target. That request is never same-origin in a browser: it is the result of the 303 from https://imdember.com/api/celestao-auth/authorize (celestao-auth.ts line 58). The Fetch standard's 'set the Sec-Fetch-Site header' walks the request's whole url list and sets cross-site as soon as one URL is not same-site with the request's origin; whether the navigation was started by the Celestao page (location.assign in world-wallet.js line 27) or by World's /fortune-connect page, the chain contains both imdember.com and celestao.com, so Chrome, Firefox and Safari send Sec-Fetch-Site: cross-site on the callback. The middleware answers 403 FORBIDDEN_ORIGIN before callback() runs, the state is not consumed and no __Host-celestao-world cookie is set. Everything behind identity() (prepare, run status, collections, PDF reopen) is therefore unreachable; the CSRF-sensitive routes are already protected by Origin, X-Requested-With, the context header and the CSRF token, and the callback by the one-time state, the __Host- cookie bound to it, PKCE and the exact redirect_uri. The suite passes only because httpx sends no Sec-Fetch-Site header (test_api.py test_oauth_callback_exact_origin_cookie_pkce_and_replay). AUDIT_REQUEST section 6 lists 'reopening a report from a real wallet' as untested, which is consistent with this never having been exercised in a browser. Fix: exempt the callback from the Sec-Fetch-Site rule, for example skip the check when request.method in ('GET','HEAD') and path=='/api/holder/world/callback' (or accept sec-fetch-mode: navigate GETs on that path only), and add a test that sends Sec-Fetch-Site: cross-site, Sec-Fetch-Mode: navigate on the callback and expects the 303 with the session cookie. Deployment files are withheld, so a proxy that strips Sec-Fetch-* headers could not be ruled out; the code as submitted blocks the flow.

**Reproduction**

Reproduced against the real app (make_app with the injected World stub, as the suite builds it, clock 1000, enabled=True). State: POST /api/holder/world/start with Origin https://celestao.com and X-Requested-With: CelestialAtlas returns 200 and sets __Host-celestao-oauth; take state from authorizeUrl. Input A (what the suite sends): GET /api/holder/world/callback?code=<43 chars>&state=<state>&lang=zh with the oauth cookie and no Sec-Fetch-Site header. Actual: 303 Location /?world_lang=zh#/saved, Set-Cookie __Host-celestao-world. Input B (what a browser sends after the 303 from imdember.com): the same request with headers Sec-Fetch-Site: cross-site, Sec-Fetch-Mode: navigate, Sec-Fetch-Dest: document. Expected: the same 303 and cookie. Actual: 403 {"error":"FORBIDDEN_ORIGIN"}, no Set-Cookie. Harness identical to test_api.py's setup; the only difference between A and B is the Sec-Fetch-Site header, which the browser adds and the suite does not.

### 2. Medium: World budgets the Celestao backend-to-backend routes (checkout prepare and token exchange) by caller IP, but every Celestao buyer reaches them through one relay egress address, so 20 prepares and 20 l

`Submission23/source/server/celestao-checkout.ts:27`

```
  const start=clock();if(!await deps.allow('celestao-checkout:'+(deps.client??'unknown')))return bad(429,'RATE_LIMITED');
```

The prepare route spends the bucket 'celestao-checkout:'+deps.client before the signature and bridge-session checks. The Worker passes client=rateLimitKey(cf-connecting-ip) and allow=key=>allow('home',key), which is AUTH_LIMITER at 20 per 60 s (Submission19 worker/app.ts lines 212-213; wrangler AUTH_LIMITER limit 20 period 60). Unlike quote and state, prepare is never called by a browser: bridge.py sends it through RelayTransport to the fixed celestao-world-egress host (relay_transport.py line 16), so cf-connecting-ip is the same address for every Celestao user and the budget is global. The overlay allows 180 POST /api/world/runs per minute per peer (limits.py line 14) and calls World once per run (paid.py line 31), so a single signed-in wallet (World login is free) sending 20 cheap prepares a minute keeps the bucket empty; World answers 429, bridge.py line 66 turns it into BridgeUnavailable and every other buyer's POST /api/world/runs answers 503 CHECKOUT_UNAVAILABLE. The same mechanism applies to the token exchange: celestao-auth.ts line 63 keys 'celestao:token:'+deps.client, also the egress address, so all Celestao logins share 20 per minute, while the authorize step is budgeted per browser IP at 20 per minute (line 46), so one browser can consume the whole login budget with 20 start/authorize/callback loops and every other user's callback is redirected to /?world_error=temporarily_unavailable (app.py line 133). Fix: key the signed backend routes by something that scales per buyer (sha256 of the bridgeSession after the signature check, or the code hash for token) or bind them to a dedicated larger limiter as BRIDGE_LIMITER did for AIMarket, and keep a separate coarse budget for unsigned traffic.

**Reproduction**

Code path: celestao-checkout.ts line 27 runs for every prepare with deps.client=rateLimitKey(cf-connecting-ip) (worker/app.ts line 212-213), and every prepare from Celestao arrives from the relay host fixed in relay_transport.py line 16. State: production Worker with AUTH_LIMITER 20/60 s, CELESTAO_PAID_ENABLED=1, overlay deployed. Input: wallet W1 signed in on celestao.com sends 21 POST /api/world/runs within 60 s with distinct requestIds and a valid input (the overlay permits 180/min). Expected: buyer W2 on another device can still prepare. Actual: from the 21st call World returns 429 RATE_LIMITED to W1 and W2 alike, bridge.py line 66 raises BridgeUnavailable, paid.py line 33 answers 503 CHECKOUT_UNAVAILABLE to W2; repeating 20 calls per minute keeps every checkout closed. Login half: one browser repeats connect 21 times in a minute; the 21st POST /api/celestao-auth/token from the egress gets 429 before the code is looked up and any other user's callback in that minute lands on /?world_error=temporarily_unavailable. The overlay side (503 to every buyer when World refuses) was run against the real app: with the World stub refusing prepare, 50 POST /api/world/runs all answered 503 CHECKOUT_UNAVAILABLE.

### 3. Low: Migration 0023 installs fortune-annual-ziwei only where the release action has already run; on any database where the release action is inserted after 0023 (fresh, staging, restored, test) the sixth p

`Submission23/source/migrations/0023_celestao_annual_ziwei.sql:24`

```
 WHERE EXISTS(SELECT 1 FROM celestao_release_actions);
```

The product INSERT is guarded by EXISTS(SELECT 1 FROM celestao_release_actions), and the one-time trigger celestao_release_enable from 0020 (lines 34-39) still inserts exactly five products and cannot run twice. The result depends on order. Production today (release action already run) gets the row at 1000 GOLD, version 1, enabled, when 0023 is applied. Any environment where migrations reach 0023 before the release action (a new environment, staging, a disaster-recovery rebuild from migrations, the test fixture) ends with five products and nothing ever installs the sixth. The catalogue (fortunePricing.ts, six products) and world_gold_products (five) then disagree: the Celestao overlay offers annual_ziwei, World prepare accepts it (the rebuilt CHECK allows the product and prepare does not read the product row) and writes a celestao_checkouts row, and goldQuote fails PRODUCT_UNAVAILABLE (Submission19 world-gold.ts line 65) for ever, while the Celestao page tells the buyer to check the original order and not create another. The only remaining installer is the admin product form, which the migration's own comment says to avoid. The suite only passes because tests/celestao-checkout.test.mjs line 102 publishes the product through f.admin(...), and celestao-release.test.mjs line 25 asserts the order-dependent behaviour as intended. The rebuild itself is sound: rows, the order_id values, the foreign key to economy_orders and the expiry index all survive. Fix: make the install order-independent, for example DROP and re-CREATE celestao_release_enable in 0023 with the sixth VALUES row (and 'fortune-annual-ziwei' in its baseline NOT EXISTS list), or add an AFTER INSERT ON celestao_release_actions trigger that inserts fortune-annual-ziwei-v1 when missing; add a test that applies every migration first and then the release action and asserts six rows.

**Reproduction**

Ran the real SQL of 0020 and 0023 in SQLite 3.45 with PRAGMA foreign_keys=ON on a minimal schema (economy_orders, world_gold_products with UNIQUE(product,version), world_gold_controls mint 0 / service 0 / topup 1). Order A (production): insert the release action, insert one checkout bound to an economy_orders id and one unbound, apply 0023 inside one transaction. Actual: six products including ('fortune-annual-ziwei',1000,1,enabled=1); both checkout rows kept with their order_id; PRAGMA foreign_key_check empty; deleting the parent order and inserting a checkout with an unknown order_id both refused with FOREIGN KEY constraint failed; index celestao_checkouts_expiry present. Order B: apply 0023, then insert the release action (before_json matching world_gold_controls). Expected: six products. Actual: SELECT product FROM world_gold_products ORDER BY product returns only fortune-bazi, fortune-human, fortune-profile, fortune-western, fortune-ziwei. Consequence on that database: POST /api/celestao-checkout/prepare with system 'annual_ziwei' returns 200 and inserts the checkout row; POST /api/celestao-checkout/quote for it fails PRODUCT_UNAVAILABLE.

### 4. Low: Repeating a prepare with the same requestId after the 15-minute window returns a signed 200 for a checkout whose expiresMs is already past, so the buyer is sent to a World checkout that answers FORTUN

`Submission23/source/server/celestao-checkout.ts:41`

```
   if(!row||row.wallet!==identity.address||row.product!==product.product||row.input_hash!==b.inputHash)return bad(409,'IDEMPOTENCY_CONFLICT');
```

World's prepare inserts with ON CONFLICT(id) DO NOTHING (line 36-39), re-reads the row and returns it signed with its original expires_ms; nothing compares row.expires_ms with now and the row is not refreshed. On the Celestao side vault.prepare returns the existing run for the same (owner, requestId) regardless of its age (vault.py lines 78-81), bridge.prepare_checkout only checks that expiresMs is an int (bridge.py line 68), and paid.prepare returns the checkoutUrl without comparing expiresMs with the clock (paid.py lines 35-37). world-wallet.js keeps one requestId per (system, input) in an in-memory Map for the life of the page (line 43), which survives a back-navigation restored from the back/forward cache after the tab went to World. goldQuote requires celestao_checkouts.expires_ms>now OR order_id IS NOT NULL (Submission19 world-gold.ts line 59), so a run prepared more than 15 minutes ago and never quoted can never be quoted; the buyer sees FORTUNE_CHECKOUT_REQUIRED on World and 'this reading has not been delivered, check its original order' on Celestao, with no path to pay until a reload draws a new requestId. No GOLD is lost. Fix: in World prepare treat an existing row with order_id IS NULL AND expires_ms<=now as expired (409 CHECKOUT_EXPIRED, or UPDATE expires_ms=now+900000 when no order is bound); in bridge.py require value['expiresMs']>self.clock(); in vault.prepare do not reuse an unpaid run whose expires_ms has passed; in world-wallet.js drop attempts.get(key) when that error comes back.

**Reproduction**

Reproduced against the real overlay app with a World stub that mirrors celestao-checkout.ts (first prepare stores expiresMs=now+900000, later prepares return the stored row unchanged). At T0: POST /api/world/runs {requestId:'same_request_id_0001',system:'annual_ziwei',input:...,saveConsent:true} -> 200, runId R, expiresAt=T0+900000. At T0+1,000,000 ms (16.7 min later, bridge grant still valid for 60 min): same body -> 200, same runId R, expiresAt=T0+900000, which is 100,000 ms in the past; the vault run is still state 'prepared' with order_id NULL and expires_ms 100,000 ms in the past; GET /api/world/runs/R -> 'prepared'. World side from the code: the client then POSTs /api/celestao-checkout/quote {runId:R,expectedAddress:W}; goldQuote's SELECT ... AND (expires_ms>? OR order_id IS NOT NULL) finds nothing, so the reply is 409 FORTUNE_CHECKOUT_REQUIRED. Expected: a fresh quotable window or a refusal at prepare time with a distinct code.

### 5. Low: Every authenticated overlay request introspects the bridge session at World, whose per-session budget is 20 per minute, so normal run polling and tab switching cut a buyer off with 503 for the rest of

`Submission23/source/integrations/celestao/celestao_world/app.py:96`

```
            current=await world.introspect(stored['bridge_session'])
```

identity() calls world.introspect on every wallet request. World limits that route per bridge session, key 'celestao:session:'+hash prefix (celestao-auth.ts line 80), on AUTH_LIMITER at 20 per 60 s. The wallet client issues more than that in ordinary use: showRun polls GET /api/world/runs/<id> every 5 s while the run is prepared or working (world-wallet.js line 69, 12 per minute), every window focus or visibility change runs session() plus collection() (lines 87-89), and each 'Check again' adds one. Once the 21st introspection in a minute is refused, bridge.py line 42 maps the 429 to BridgeUnavailable and app.py line 100 answers 503 WORLD_AUTH_UNAVAILABLE for every wallet route, including the status poll and the report read; the client shows 'Temporarily unavailable'. The World session itself stays valid. Fix: cache a successful introspection per token for a short window bounded by the stored expires_at, or skip introspection on the status poll and rely on still_current(), or give 'celestao:session:' a larger budget.

**Reproduction**

Reproduced against the real overlay app with a World stub that counts introspections and refuses the 21st in a window (as AUTH_LIMITER does): 21 GET /api/world/runs/<id> with a valid session, context header and X-Requested-With produced 21 introspections; responses 1-20 were 200 (run status 'prepared'), the 21st was 503 {'detail':'WORLD_AUTH_UNAVAILABLE'}, and GET /api/world/session then also answered 503. Expected: a buyer who leaves the World payment tab open and returns to the Celestao tab a few times within a minute keeps seeing their run status.

### 6. Low: Sealed birth data is written to the vault before World admits the checkout, never expires and is never pruned; claim accepts a run hours after its own expires_ms

`Submission23/source/integrations/celestao/celestao_world/paid.py:30`

```
        run=vault.prepare(ident.wallet,value.requestId,value.system,payload,True,clock())
```

prepare() stores the run (sealed input up to 32,768 bytes, vault.py line 75) and only then asks World (line 31). When World refuses (429 from the shared budget, 503, product not yet published) the run stays in runs for ever: no code reads runs.expires_ms, nothing deletes runs, records, wallet_sessions or celestao_checkouts, and vault.claim (vault.py line 106) accepts a run whose expires_ms passed long ago, so the 15-minute expiry stamped at vault.py line 84 has no effect. Consequences: an authenticated user can grow wallet.sqlite by up to 180 x 32 KB per minute per peer with no World involvement, and birth data of users who never paid is retained indefinitely under a consent that promised storage of a paid report. Fix: enforce expires_ms in claim() and status() for runs without an order, add a periodic bounded DELETE of runs with state='prepared' AND order_id IS NULL AND expires_ms<=now (and expired wallet_sessions and oauth_starts), and consider asking World before sealing the input or removing the run when World refuses.

**Reproduction**

Reproduced against the real overlay app. State: World stub refuses every prepare_checkout with BridgeUnavailable (as after a 429/503). Input: 50 POST /api/world/runs from one session with distinct requestIds and a 30 KB input. Expected: nothing persisted when no checkout exists. Actual: every response is 503 CHECKOUT_UNAVAILABLE, yet SELECT count(*),sum(length(input_blob)) FROM runs returns 50 rows and 1,505,000 sealed bytes. Then vault.prepare at NOW (expires_ms=NOW+900000), bind_order, and vault.claim at NOW+4h returns a live attempt although the run expired 3 h 45 min earlier.

### 7. Low: Celestao answers the delivery call only after the whole computation finishes, while World aborts the call after 10 seconds; a report that takes longer is delivered only on a later cron pass after the

`Submission23/source/integrations/celestao/celestao_world/paid.py:74`

```
            await asyncio.shield(task)
```

deliver() claims the run and then awaits the shielded compute task before building the envelope, so the HTTP response time equals the chart computation time. World's provider sets AbortSignal.timeout(10000) (celestao-delivery.ts line 18) and treats the abort as DELIVERY_UNAVAILABLE; the computation continues on Celestao (shield) and completes, but World's job stays 'working' until its lease lapses and the next pass re-delivers, during which the checkout page shows the order as not delivered. The annual reading composes a full HTML report with a wording pass (evidence: 95 charts, 81 edited) on a 1 CPU / 1 GB deployment and AUDIT_REQUEST section 6 lists its timing as untested, so whether it exceeds 10 s is a lead, not verified here; the mismatch between an unbounded wait and a 10 s abort is in the code. No funds are lost. Fix: when the claim succeeds, start the task and return kind='pending' immediately (World already polls), or bound the awaited time with asyncio.wait_for on the shielded task and fall back to 'pending'.

**Reproduction**

Reproduced against the real overlay app with a processor whose compute sleeps 3 s: the signed POST /api/internal/world-gold/deliver for an annual_ziwei run answered after 3.0 s with kind=delivered, i.e. the response is held for the full computation. State for the failure: a computation longer than 10 s. Expected: World receives kind=pending within its timeout and retries. Actual: World's fetch aborts at 10 s, celestao-delivery.ts throws DELIVERY_UNAVAILABLE, and the receipt is collected only on a later pass.

### 8. Info: Per-peer wallet rate limit keys on the socket peer, so behind a reverse proxy that is not configured to pass the client address the whole site shares 20 sign-ins and 180 wallet calls per minute, and W

`Submission23/source/integrations/celestao/celestao_world/limits.py:12`

```
        now=time.monotonic();peer=(scope.get('client') or ('unknown',0))[0]
```

WalletLimits keys on scope['client'][0] and deliberately ignores X-Forwarded-For. That is only a per-visitor limit when the ASGI server terminates client TCP connections directly. production.py configures no proxy headers and the deployment folder is withheld, so whether a reverse proxy sits in front could not be verified. If one does, all requests share one key per group: 20 POST /api/holder/world/start per minute for all visitors, and 180 per minute shared by every wallet page's collection loads, the 5-second run poll (world-wallet.js line 69), the PDF reopen and World's signed POST /api/internal/world-gold/deliver, which is in the same 'wallet' group (line 13-14). Fifteen open run panels saturate it and World's delivery then receives 429, treated as DELIVERY_UNAVAILABLE and retried later. Fix: run uvicorn with --proxy-headers and --forwarded-allow-ips set to the proxy so scope['client'] is the real peer, or key authenticated wallet routes by the session token hash, and give the HMAC-authenticated deliver route its own group.

**Reproduction**

Mechanism against the code: with the middleware mounted and one client address for all requests, the 21st POST /api/holder/world/start in a minute returns 429 RATE_LIMITED with Retry-After: 60 (the suite's test_bounded_inputs_rate_limits_and_original_application shows the 20th refusal from one address), and after 180 GET /api/world/runs/<id> in a minute from that address the next POST /api/internal/world-gold/deliver returns 429 because both spend the (peer,'wallet') bucket. Precondition not verifiable from the submission: a reverse proxy without proxy-header configuration in front of the ASGI server.

### 9. Info: World never prunes celestao_checkouts; every prepare that is never quoted leaves a permanent row

`Submission23/source/server/celestao-auth.ts:100`

```
export async function pruneCelestaoAuth(db:D1Database,now:number){
```

pruneCelestaoAuth deletes expired auth codes and bridge sessions only. celestao_checkouts has an expiry index (0023 recreates celestao_checkouts_expiry) but no statement in celestao-auth.ts, celestao-checkout.ts or world-gold.ts deletes expired unordered rows; goldQuote only updates them. Growth is bounded by the shared prepare budget (20 per minute per location). Rows with order_id IS NULL and expires_ms<=now can be deleted safely because goldQuote requires expires_ms>now for an unordered row. Fix: add a bounded DELETE for those rows to the same scheduled prune.

**Reproduction**

State: production after the overlay opens. Input: N prepares that never reach a quote. Expected: expired unordered rows disappear with the other Celestao housekeeping. Actual: SELECT count(*) FROM celestao_checkouts WHERE order_id IS NULL AND expires_ms<=now grows without bound; the prune at lines 100-106 touches only celestao_auth_codes and celestao_bridge_sessions.

### 10. Info: The catalogue version string was not bumped when the sixth product was added, so celestao-gold-v1-20261025 now names two different price lists

`Submission23/source/src/shared/fortunePricing.ts:3`

```
export const FORTUNE_PRICING_VERSION='celestao-gold-v1-20261025';
```

FORTUNE_PRICING_VERSION and fortune_catalog.json 'version' are both still celestao-gold-v1-20261025. Before 754ef44 that string meant five products; now it means six. celestao_release_actions.catalogue is CHECK-constrained to the same string (0020 line 20), so the immutable release record cannot say which list was released, and an overlay or cached catalogue with five products and one with six report the same version; world-wallet.js has to fall back to catalog.products.length!==6 (line 102).

**Reproduction**

Compare fortune_catalog.json at d5d7912 (five products, version celestao-gold-v1-20261025) with the one at 754ef44 (six products, same version). Expected: a distinct version for a changed list. Actual: any check keyed on the version cannot distinguish them.

### 11. Info: System-to-product id mapping differs between World and Celestao: JS replace('_','-') changes only the first underscore, Python str.replace changes all; identical for the six current systems

`Submission23/source/src/shared/fortunePricing.ts:9`

```
const product=(system:FortuneSystem,nameZh:string,nameEn:string,gold:string,listGold=gold):FortuneProduct=>Object.freeze({system,product:'fortune-'+system.replace('_','-'),nameZh,nameEn,gold,listGold,version:1});
```

World builds the product id with String.prototype.replace('_','-') on a string pattern, which replaces only the first occurrence. Celestao builds the same id in bridge.py line 68 and paid.py line 68 with Python str.replace('_','-'), which replaces every occurrence. They agree for annual_ziwei. A future system with two underscores would map differently on each side: every prepare would fail BridgeUnavailable (the id World returns is not the one bridge.py expects) and every delivery would 409 ORDER_BINDING_REQUIRED after the buyer had paid. Fix: use replaceAll('_','-') (or a regex with the g flag) in fortunePricing.ts, and add a shared test that derives every id both ways.

**Reproduction**

For system 'annual_ziwei_v2': TypeScript 'fortune-'+'annual_ziwei_v2'.replace('_','-') gives 'fortune-annual-ziwei_v2'; Python 'fortune-'+'annual_ziwei_v2'.replace('_','-') gives 'fortune-annual-ziwei-v2'. Expected: identical ids. Actual: different, so the prepare response check at bridge.py line 68 rejects the id World returns. Not reachable with the six current systems.

---

Judge's submission `0611c8b4c3ea5283c34952b3a5ee73e6b968d9eee94d2def03bb7345fe7ee6fe`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
