diff --git a/.github/workflows/practice-deferred.yml b/.github/workflows/practice-deferred.yml new file mode 100644 index 00000000..2f79783f --- /dev/null +++ b/.github/workflows/practice-deferred.yml @@ -0,0 +1,69 @@ +name: Practice deferred acceptance +on: + pull_request: + paths: + - 'src/castle-practice.js' + - 'src/app.js' + - 'tools/build*.cjs' + - 'tools/check-android-artifact.cjs' + - 'tests/*practice*.cjs' + - 'tests/browser_practice_deferred.py' + - '.github/workflows/practice-deferred.yml' + workflow_dispatch: +permissions: + contents: read +jobs: + practice-deferred: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - name: Capture immutable candidate inputs + env: + EXPECTED_HEAD: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$EXPECTED_HEAD" + mkdir -p test-results/practice-deferred + printf '{"sourceSha":"%s","treeSha":"%s"}\n' "$(git rev-parse HEAD)" "$(git rev-parse HEAD^{tree})" > test-results/practice-deferred/source-head.json + git archive --format=tar --prefix=source/ HEAD | gzip -n > test-results/practice-deferred/source.tar.gz + sha256sum src/castle-practice.js src/app.js tools/build.cjs tools/build-android.cjs tools/check-android-artifact.cjs tests/browser_practice_deferred.py > test-results/practice-deferred/inputs.sha256 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '22' + cache: npm + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: '3.13' + - run: npm ci + - name: Require full pinned verification and original byte ceilings + run: | + set -o pipefail + npm run verify 2>&1 | tee test-results/practice-deferred/verify.log + - run: python -m pip install -r requirements-dev.txt + - name: Install Chromium with Ubuntu repositories + run: | + test -s /etc/apt/sources.list.d/ubuntu.sources + printf '%s\n' 'Dir::Etc::sourcelist "/etc/apt/sources.list.d/ubuntu.sources";' 'Dir::Etc::sourceparts "-";' | sudo tee /etc/apt/apt.conf.d/99alibi-ci-sources >/dev/null + python -m playwright install --with-deps chromium + - name: Native app-owned practice counts through failure and recovery + run: | + set -o pipefail + python tests/browser_practice_deferred.py 2>&1 | tee test-results/practice-deferred/browser.log + - name: Check exact source remained unchanged + if: ${{ !cancelled() }} + run: | + sha256sum --check test-results/practice-deferred/inputs.sha256 + git diff --exit-code + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + if: always() + with: + name: practice-deferred-${{ github.event.pull_request.head.sha || github.sha }} + path: | + test-results/practice-deferred/ + build-info.json + retention-days: 14 + if-no-files-found: error diff --git a/docs/qa/2026-10-07-backlog/issue-389.md b/docs/qa/2026-10-07-backlog/issue-389.md new file mode 100644 index 00000000..97eac02b --- /dev/null +++ b/docs/qa/2026-10-07-backlog/issue-389.md @@ -0,0 +1,33 @@ +# Issue 389 - deferred Vault follow-ups + +2026-10-07, branch codex/backlog-389-20261007, based on f084aa6. + +## Changed + +Castle practice accepts the registered scalar listing metadata as the identity of a historical firstCompletedAt run while a Vault chunk is pending or failed. Loaded definitions still require the exact saved body; a current completion still requires the normal completion predicate. The build content revision now hashes both initial and deferred definition scripts. IDs, revisions, lazy loading and save format are unchanged. + +Item 2 already has a pending route, loading indicator and timeout in current source. The existing deferred-route and deferred-loading regressions pass; no new app.js change was required. + +## Verified + +Coordinator npm.cmd run build succeeds. Focused Node suite with --test-isolation=none --test-concurrency=1 over castle-practice, content-manifest-revision, official-deferred, deferred-route, deferred-loading and budget passes 30/30, no skips/cancellations (112755ms). Emitted identity matches both payloads. Prettier on all four changed files passes. + +Measured JavaScript gzip: 134941 bytes against the unchanged strict 134944-byte limit. Core offline bytes: 1892675. Integration has only three gzip bytes of headroom, so a combined exact-head build must re-prove the existing budget; this slice does not relax it. + +## NOT verified + +Physical Android/TalkBack and actual browser practice pending/failure rendering are unverified. No deployment, hosted CI or publication. Independent review remains pending. The first process-isolated test run used an incorrect budget filename and stale build; its emitted identity assertion failed. Rebuilding and the scoped no-isolation run above provide the final proof. The initial diagnosis of a hanging process was not sustained: the runner was consuming CPU validating the catalogue. + +## Residual risk + +Grok 4.7 high reached its 25-turn cap after the useful changes, so the coordinator owns completion. Muse lane creation was refused because current host kraspyon differs from registry owner desktop-ihkoojs; no bypass or Muse job ran. HUMAN_TODO.md remains authoritative and was not edited. Root owns central STATE updates and publication. Existing PR #572 practice interactions require root integration review. + +## Final Android identity correction + +The first independent Grok review found a HIGH producer/consumer defect: the Android runtime copied the complete web content revision, but its receipt and checker still expected only the startup script hash. A clean baseline build:android passed, while the added regression failed on receipt hash 95d1e511... versus complete revision 7a76937c... (exit 1). An earlier baseline attempt failed the clean-source precondition after a test edit; that attempt is not counted as regression proof. + +The receipt now preserves the runtime revision. The checker computes the authoritative shared helper from exactly one startup and one deferred UTF-8 asset. The new regression compares web, Android runtime and receipt revisions, and requires the artifact inspector to accept the result. Other direct assumptions were searched: standalone preview-house hashes the complete catalogue JSON and needs no change. + +Coordinator checks on the correction: npm.cmd run build:android exit 0; npm.cmd run check:android exit 0; Android-build, Android-boot-target, platform-identity, content-manifest-revision and budget suites with no isolation and concurrency 1 pass 24/24, zero skips/cancellations (49824ms). Prettier and diff check pass. Measured JavaScript gzip remains 134941 bytes; core offline bytes 1892676. No budget ceilings, save formats, IDs or database versions change. + +Second and final independent review: fresh read-only GPT-6.1 Sol medium reviewed only the three-file fix diff, found no CRITICAL/HIGH blockers or additional actionable findings. Its static check confirms unique asset selection, UTF-8 input order, receipt/runtime agreement and the regression. It ran no tests or physical-device checks. The subsequent amendment only adds this evidence; exact-head artifact regeneration/check remains the final local step. HUMAN_TODO.md remains authoritative and unchanged. diff --git a/src/castle-practice.js b/src/castle-practice.js index 921c618c..696e491a 100644 --- a/src/castle-practice.js +++ b/src/castle-practice.js @@ -143,7 +143,16 @@ } function sameDefinition(left, right) { - return !!left && !!right && stable(left) === stable(right); + // A pending or failed Vault chunk leaves a listing: no solution, and its public fields are + // scalars. A loaded definition still has to match exactly, including its body. + return ( + !!left && + !!right && + (stable(left) === stable(right) || + (!right.solution && + right.id && + Object.keys(right).every((key) => left[key] === right[key]))) + ); } function dateMarker(value) { diff --git a/tests/android-build.test.cjs b/tests/android-build.test.cjs index 007cca64..4747ec73 100644 --- a/tests/android-build.test.cjs +++ b/tests/android-build.test.cjs @@ -10,6 +10,7 @@ const { ANDROID_DIST, deriveAndroidPayload, sourceSha } = require('../tools/buil const { inspectAndroidArtifact } = require('../tools/check-android-artifact.cjs'); const { checkPublicPayload } = require('../tools/sync-android.cjs'); const { readIdentity, payloadDigest } = require('../tools/platform-identity.cjs'); +const { contentManifestRevision } = require('../tools/build.cjs'); const ROOT = path.resolve(__dirname, '..'); const WEB_DIST = path.join(ROOT, 'dist'); @@ -36,6 +37,26 @@ test('generated Android payload satisfies the closed artifact contract', () => { assert.ok(result.bytes > 0); }); +test('web, Android runtime and receipt share the complete content revision', () => { + const assetNames = fs.readdirSync(path.join(WEB_DIST, 'assets')); + const read = (pattern) => { + const names = assetNames.filter((name) => pattern.test(name)); + assert.equal(names.length, 1); + return fs.readFileSync(path.join(WEB_DIST, 'assets', names[0]), 'utf8'); + }; + const expected = contentManifestRevision( + read(/^official-content\.[0-9a-f]{12}\.js$/), + read(/^official-deferred\.[0-9a-f]{12}\.js$/), + ); + const web = readIdentity(WEB_DIST).identity; + const android = readIdentity(ANDROID_DIST).identity; + const receipt = readJson(path.join(ANDROID_DIST, 'android-build-identity.json')); + assert.equal(web.contentManifestRevision, expected); + assert.equal(android.contentManifestRevision, expected); + assert.equal(receipt.contentManifestRevision, expected); + assert.deepEqual(inspectAndroidArtifact().errors, []); +}); + test('Android replaces the web runtime identity with explicit browser-preview capabilities', () => { const web = readIdentity(WEB_DIST); const android = readIdentity(ANDROID_DIST); diff --git a/tests/browser_practice_deferred.py b/tests/browser_practice_deferred.py new file mode 100644 index 00000000..646c21dd --- /dev/null +++ b/tests/browser_practice_deferred.py @@ -0,0 +1,177 @@ +"""Emitted practice adapter and native IndexedDB across deferred delivery states. + +A disposable local origin, not a physical-device or deployed-service test. +""" +import hashlib +import json +import os +import subprocess +import threading +from functools import partial +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +from playwright.sync_api import sync_playwright + +ROOT = Path(__file__).resolve().parents[1] +OUT = ROOT / 'test-results' / 'practice-deferred' +FIXTURE = b'''Synthetic practice seed +''' + + +class Handler(SimpleHTTPRequestHandler): + def do_GET(self): + if self.path == '/__seed__': + data, mime = FIXTURE, 'text/html' + elif self.path in ('/__source__/core.js', '/__source__/engines.js'): + data = (ROOT / 'src' / self.path.rsplit('/', 1)[1]).read_bytes() + mime = 'text/javascript' + else: + return super().do_GET() + self.send_response(200) + self.send_header('Content-Type', mime) + self.send_header('Content-Length', str(len(data))) + self.end_headers() + self.wfile.write(data) + + def log_message(self, *_): + pass + + +def snapshot(page): + value = page.evaluate('()=>AlibiDiagnostics.getPracticeSnapshot()') + assert value['available'], value + assert value['rooms']['observatory']['completed'] == 1, value['rooms']['observatory'] + assert len(value['rooms']) == 13 + return value['rooms']['observatory'] + + +def stored(page, key): + return page.evaluate('''key=>new Promise((resolve,reject)=>{ + const request=indexedDB.open('alibi-device',1); + request.onerror=()=>reject(request.error); + request.onsuccess=()=>{ + const db=request.result,tx=db.transaction('runs'),read=tx.objectStore('runs').get(key); + let result; + read.onsuccess=()=>result=read.result; + tx.oncomplete=()=>{db.close();resolve(result);}; + tx.onabort=()=>{db.close();reject(tx.error);}; + }; + })''', key) + + +def scenario(browser, base, width, puzzle, report): + context = browser.new_context(viewport={'width': width, 'height': 900}, + service_workers='block', reduced_motion='reduce') + held = [] + pattern = '**/official-deferred.*.js' + context.route(pattern, lambda route: held.append(route)) + try: + page = context.new_page() + page.set_default_timeout(15000) + page.goto(base + '/__seed__') + original = page.evaluate('''async puzzle=>{ + const value={schemaVersion:1,key:puzzle.id+'@'+puzzle.revision,rev:4,puzzle, + state:AlibiCore.registry[puzzle.type].initial(puzzle),undo:[],redo:[],moves:0, + hints:0,elapsed:0,completedAt:null,firstCompletedAt:'2026-09-01T10:00:00.000Z', + updatedAt:'2026-09-01T10:00:00.000Z',note:'synthetic historical completion'}; + await new Promise((resolve,reject)=>{ + const request=indexedDB.open('alibi-device',1); + request.onupgradeneeded=()=>{ + for(const name of ['runs','packs','meta'])request.result.createObjectStore(name,{keyPath:'key'}); + }; + request.onerror=()=>reject(request.error); + request.onsuccess=()=>{ + const db=request.result,tx=db.transaction('runs','readwrite'); + tx.objectStore('runs').put({key:value.key,value}); + tx.oncomplete=()=>{db.close();resolve();}; + tx.onabort=()=>{db.close();reject(tx.error);}; + }; + }); + return {key:value.key,value}; + }''', puzzle) + page.goto(base + '/#/home', wait_until='domcontentloaded') + page.wait_for_function('()=>!!globalThis.AlibiDiagnostics?.getPracticeSnapshot') + page.evaluate('''()=>{ + window.definitionResult=null; + ALIBI_DEFERRED.ensure().then(()=>window.definitionResult='loaded', + error=>window.definitionResult=error.message); + }''') + page.wait_for_function('()=>Array.from(document.scripts).some(s=>s.src.includes("official-deferred."))') + assert held, 'the real deferred script request must be pending' + assert page.evaluate('ALIBI_DEFERRED.ready') is False + pending = snapshot(page) + assert stored(page, original['key']) == original + report['cases'].append({'width': width, 'phase': 'pending', 'room': pending}) + for route in held: + route.abort('failed') + held.clear() + page.wait_for_function('()=>typeof window.definitionResult==="string"') + assert page.evaluate('definitionResult') == 'Puzzle definitions did not load.' + assert page.evaluate('ALIBI_DEFERRED.ready') is False + failed = snapshot(page) + assert failed == pending + assert stored(page, original['key']) == original + report['cases'].append({'width': width, 'phase': 'failed', 'room': failed}) + context.unroute(pattern) + page.evaluate('async()=>await ALIBI_DEFERRED.ensure()') + assert page.evaluate('ALIBI_DEFERRED.ready') is True + loaded = snapshot(page) + assert loaded == pending + assert stored(page, original['key']) == original + report['cases'].append({'width': width, 'phase': 'loaded', 'room': loaded}) + # The same app-owned snapshot survives a new page after definitions load. + page.reload(wait_until='domcontentloaded') + page.wait_for_function('()=>!!globalThis.AlibiDiagnostics?.getPracticeSnapshot') + page.evaluate('async()=>await ALIBI_DEFERRED.ensure()') + assert snapshot(page) == pending + assert stored(page, original['key']) == original + report['cases'].append({'width': width, 'phase': 'reload', 'room': pending}) + finally: + context.close() + + +def run(): + OUT.mkdir(parents=True, exist_ok=True) + report = {'passed': False, 'scope': 'emitted app-owned adapter, native IndexedDB, real deferred request', + 'cases': [], 'sourceSha256': {p: hashlib.sha256((ROOT / p).read_bytes()).hexdigest() + for p in ('src/castle-practice.js', 'src/app.js', 'tools/build.cjs', + 'tools/build-android.cjs', 'tools/check-android-artifact.cjs')}} + server = ThreadingHTTPServer(('127.0.0.1', 0), partial(Handler, directory=str(ROOT / 'dist'))) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + report['identity'] = json.loads((ROOT / 'build-info.json').read_text())['platformBuild'] + puzzle = json.loads(subprocess.check_output([ + 'node', '-e', "console.log(JSON.stringify(require('./tools/official-catalogue.cjs').load(process.cwd()).puzzles.find(p=>p.id==='vault-binary-01')))" + ], cwd=ROOT, text=True)) + assert puzzle['type'] == 'binary' and puzzle['solution'] + with sync_playwright() as pw: + options = {'headless': True, 'args': ['--no-sandbox']} + executable = os.environ.get('CHROMIUM_PATH') + if not executable and Path('/usr/bin/chromium').exists(): + executable = '/usr/bin/chromium' + if executable: + options['executable_path'] = executable + browser = pw.chromium.launch(**options) + try: + report['browserVersion'] = browser.version + for width in (390, 1280): + scenario(browser, f'http://127.0.0.1:{server.server_port}', width, puzzle, report) + finally: + browser.close() + assert len(report['cases']) == 8 + report['passed'] = True + except Exception as error: + report['failure'] = f'{type(error).__name__}: {error}' + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + (OUT / 'results.json').write_text(json.dumps(report, indent=2), encoding='utf-8') + print(json.dumps(report, indent=2), flush=True) + return 0 if report['passed'] else 1 + + +if __name__ == '__main__': + raise SystemExit(run()) diff --git a/tests/castle-practice.test.cjs b/tests/castle-practice.test.cjs index 37a719ce..4afadd44 100644 --- a/tests/castle-practice.test.cjs +++ b/tests/castle-practice.test.cjs @@ -132,6 +132,62 @@ test('a malformed committed read makes the adapter unavailable and open accepts assert.equal(await adapter.open(puzzle.id, 'kitchen'), false); }); +test('a saved Vault completion counts from its listing before load, after load failure, and after the definition arrives', async () => { + const api = practiceApi(), + { listing } = require('../tools/build-official-content.cjs'), + catalogue = load(process.cwd()), + full = catalogue.puzzles.find((puzzle) => puzzle.id === 'vault-binary-01'), + entry = listing(full), + puzzles = catalogue.puzzles.map((puzzle) => (puzzle === full ? entry : puzzle)), + partial = { ...catalogue, puzzles }, + binary = puzzles.filter((puzzle) => puzzle.type === 'binary').length; + assert.equal(full.type, 'binary'); + assert.equal(entry.solution, undefined); + const room = async (runs) => + ( + await api + .create({ + catalogue: partial, + readRuns: async () => runs, + isCurrentCompletion: () => false, + }) + .snapshot() + ).rooms.observatory; + const saved = runFor(full, { firstCompletedAt: '2026-09-01T10:00:00.000Z' }); + const pending = await room([saved]); + assert.equal(pending.completed, 1, 'listing identity keeps the saved completion'); + assert.equal(pending.total, binary); + assert.equal( + ( + await room([ + runFor( + { ...full, title: 'Changed definition' }, + { firstCompletedAt: saved.firstCompletedAt }, + ), + ]) + ).completed, + 0, + ); + assert.equal( + (await room([runFor(full, { completedAt: '2026-09-02T10:00:00.000Z' })])).completed, + 0, + 'a current completion still needs the definition body', + ); + for (const key of Object.keys(entry)) delete entry[key]; + Object.assign(entry, full); + assert.equal((await room([saved])).completed, 1); + assert.equal((await room([saved])).total, binary); + assert.equal( + ( + await room([ + runFor({ ...full, solution: ['tampered'] }, { firstCompletedAt: saved.firstCompletedAt }), + ]) + ).completed, + 0, + 'once the definition is present only the exact body counts', + ); +}); + test('the Castle panel emits bound practice controls and escapes catalogue labels', async () => { const { practicePanel } = await import('../src/castle/practice.mjs'); const html = practicePanel( diff --git a/tests/content-manifest-revision.test.cjs b/tests/content-manifest-revision.test.cjs new file mode 100644 index 00000000..158ad0bb --- /dev/null +++ b/tests/content-manifest-revision.test.cjs @@ -0,0 +1,32 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { contentManifestRevision } = require('../tools/build.cjs'); +const { readIdentity, sha256 } = require('../tools/platform-identity.cjs'); + +test('content identity binds deferred definition bytes, not only the startup script', () => { + const startup = 'startup-script'; + const revision = contentManifestRevision(startup, 'definitions-a'); + assert.equal(revision, sha256(`${startup}\0definitions-a`)); + assert.notEqual(revision, sha256(startup)); + assert.notEqual(revision, contentManifestRevision(startup, 'definitions-b')); + assert.notEqual(revision, contentManifestRevision('other-startup', 'definitions-a')); +}); + +test('the emitted web identity covers the deferred definition file', () => { + const root = path.resolve(__dirname, '..'); + const names = fs.readdirSync(path.join(root, 'dist/assets')); + const read = (pattern) => { + const found = names.filter((name) => pattern.test(name)); + assert.equal(found.length, 1, String(pattern)); + return fs.readFileSync(path.join(root, 'dist/assets', found[0]), 'utf8'); + }; + const content = read(/^official-content\.[a-f0-9]{12}\.js$/); + const deferred = read(/^official-deferred\.[a-f0-9]{12}\.js$/); + const { identity } = readIdentity(path.join(root, 'dist')); + assert.equal(identity.contentManifestRevision, contentManifestRevision(content, deferred)); + assert.notEqual(identity.contentManifestRevision, sha256(content)); +}); diff --git a/tools/build-android.cjs b/tools/build-android.cjs index a42ddd72..c093e10b 100644 --- a/tools/build-android.cjs +++ b/tools/build-android.cjs @@ -401,7 +401,7 @@ function deriveAndroidPayload({ sourceSha: commit, artifactSha256: treeDigest(target, new Set(['android-build-identity.json'])), webBuild: webInfo.build, - contentManifestRevision: content.sha256, + contentManifestRevision: runtimeIdentity.contentManifestRevision, rulesCompatibility: {}, rulesSourceDigest: sourceDigest(root), saveEnvelopeVersions: { diff --git a/tools/build.cjs b/tools/build.cjs index 7cc13ab1..84065d9e 100644 --- a/tools/build.cjs +++ b/tools/build.cjs @@ -369,7 +369,7 @@ function build() { ...source, payloadSha256: payloadDigest(DIST), appVersion: VERSION, - contentManifestRevision: sha256(contentSource), + contentManifestRevision: contentManifestRevision(contentSource, deferredSource), rulesCompatibility: {}, }; const platformIdentity = writeIdentity(DIST, platformBuild); @@ -569,6 +569,11 @@ self.addEventListener('fetch',event=>{const r=event.request,u=new URL(r.url);if( write(path.join(ROOT, 'build-info.json'), JSON.stringify(info, null, 2)); console.log(JSON.stringify(info, null, 2)); } +// Startup content names the deferred chunk, but a 12-character filename hash is not the +// definition identity. Bind both payloads so two different Vault bodies cannot share it. +function contentManifestRevision(contentSource, deferredSource) { + return sha256(`${contentSource}\0${deferredSource}`); +} if (require.main === module) build(); module.exports = { build, @@ -577,4 +582,5 @@ module.exports = { PATH_ROUTE_ALIASES, pathRouteAliasDocument, writePathRouteAliases, + contentManifestRevision, }; diff --git a/tools/check-android-artifact.cjs b/tools/check-android-artifact.cjs index 48e5dfae..ae5cd330 100644 --- a/tools/check-android-artifact.cjs +++ b/tools/check-android-artifact.cjs @@ -5,6 +5,7 @@ const crypto = require('node:crypto'); const fs = require('node:fs'); const path = require('node:path'); const { payloadDigest, readIdentity, identitySource } = require('./platform-identity.cjs'); +const { contentManifestRevision } = require('./build.cjs'); const { ANDROID_DIST, HOST_ONLY, @@ -94,16 +95,18 @@ function expectedCosts(entries) { function currentContentManifestRevision(root, errors) { const directory = path.join(root, 'dist', 'assets'); - const candidates = files(directory).filter((filename) => - /^official-content\.[0-9a-f]{12}\.js$/.test(path.basename(filename)), - ); - if (candidates.length !== 1) { - errors.push( - `Current web build needs exactly one official-content asset; found ${candidates.length}.`, - ); - return ''; + const assets = files(directory); + const sources = []; + for (const name of ['official-content', 'official-deferred']) { + const pattern = new RegExp(`^${name}\\.[0-9a-f]{12}\\.js$`); + const candidates = assets.filter((filename) => pattern.test(path.basename(filename))); + if (candidates.length !== 1) { + errors.push(`Current web build needs exactly one ${name} asset; found ${candidates.length}.`); + return ''; + } + sources.push(fs.readFileSync(candidates[0], 'utf8')); } - return sha256(fs.readFileSync(candidates[0])); + return contentManifestRevision(...sources); } function inspectAndroidArtifact({