Whitepaper · 11
Руководство по реализации
От чистого клона до развёрнутого, снабжённого хуками и аттестованного токена партии, шаг за шагом. Каждый блок JavaScript в этой главе скопирован из test/standard/ImplementationGuide.test.js и выполняется в CI; если бы блок здесь был неверным, сборка завершилась бы ошибкой.
Как пользоваться этим руководством
Шаги опираются друг на друга и используют общие переменные: token, attestations, подписантов и id, созданные по ходу. При первом чтении их следует читать по порядку. Запустить их самостоятельно можно командой:
npx hardhat test test/standard/ImplementationGuide.test.js
| Шаг | Что получается | Кто действует | Основные вызовы |
|---|---|---|---|
| 1 · Подготовка | скомпилированная, протестированная рабочая копия | разработчик | npm ci, npm test |
| 2 · Развёртывание | прокси токена + реестр аттестаций | развёртывающая сторона | upgrades.deployProxy, ARexAttestations |
| 3 · Роли | операторы с минимальными привилегиями | администратор | grantRole |
| 4 · Создание | партия с коммитментом метаданных | эмитент | createBatch |
| 5 · Чтение | запись партии и предложение | любой | batchOf, totalSupply(uint256), parentsOf |
| 6 · Перевод | проверенный перевод | держатель | transferCheck, safeTransferFrom |
| 7 · Родословная | разделение, объединение, переработка | держатель / преобразователь | transform |
| 8 · Статус | обновления жизненного цикла и метаданных | роль статуса | setBatchStatus, setBatchMetadata |
| 9 · Хуки | упорядоченный конвейер правил | администратор хуков | registerHook, getHooks |
| 10 · Аттестация | доказательства, разблокирующие переводы | доверенный эмитент | setTrustedIssuer, attest |
| 11 · Санкции | допуск с указанием источника и TTL | менеджер допуска | setSanctioned |
| 12 · Принудительное исполнение | заморозка, приостановка, принудительный перевод | роли ENFORCER / PAUSER | setFrozenTokens, forcedTransfer |
| 13 · Погашение | конец жизненного цикла лота | роль статуса, держатель | setBatchStatus(…, 255, …), burn |
| 14 · Индексация | граф родословной из событий | индексатор | queryFilter(BatchTransformed) |
| 15 · Обновление | проверенное обновление реализации | роль обновления | validateUpgrade, upgradeProxy |
1 · Подготовка
Node.js 20 или 22 LTS и npm. Зависимости зафиксированы в package-lock.json (OpenZeppelin 5.3.0); следует использовать npm ci, чтобы собираемая версия совпадала с той, которую собирает CI.
git clone https://github.com/Arex-Corp/A-REX.git && cd A-REX
npm ci
npm run compile
npm test # 46 tests: reference implementation, hooks, extensions, invariants, this guide
npm run interface-ids # prints the ERC-165 ids; they must match the specification
Приведённые ниже фрагменты используют три небольших вспомогательных определения и называют десять аккаунтов. explain преобразует код отказа обратно в его название; список — это набор кодов, определённых в ARexCodes.
const commodity = (scheme, code) => ethers.keccak256(ethers.toUtf8Bytes(`${scheme}:${code}`));
const unit = (u) => ethers.encodeBytes32String(u);
const ref = (text) => ethers.keccak256(ethers.toUtf8Bytes(text));
const REASONS = Object.fromEntries(
["PAUSED", "FROZEN", "SENDER_INELIGIBLE", "RECEIVER_INELIGIBLE", "SANCTIONED", "BATCH_NOT_FOUND",
"BATCH_RETIRED", "BATCH_EXPIRED", "BATCH_NOT_PRODUCED", "ATTESTATION_MISSING", "SEASON_CLOSED",
"IN_TRANSIT", "LIMIT_EXCEEDED", "DOCUMENT_MISSING"].map((n) => [ethers.keccak256(ethers.toUtf8Bytes(`AREX:${n}`)), n]),
);
const explain = (code) => (code === ethers.ZeroHash ? "OK" : REASONS[code] ?? code);
const [admin, issuer, farmer, buyer, coop, mill, warehouse, certifier, enforcer, custodian] = await ethers.getSigners();
2 · Развёртывание
Эталонный токен обновляется по схеме UUPS, поэтому он развёртывается за прокси ERC-1967 с помощью плагина OpenZeppelin upgrades, который также проверяет раскладку хранилища. Реестр аттестаций — обычный контракт, привязанный к токену.
const ARexToken = await ethers.getContractFactory("ARexToken");
const token = await upgrades.deployProxy(ARexToken, [admin.address, "ipfs://<cid>/{id}.json"], { kind: "uups" });
await token.waitForDeployment();
const attestations = await (await ethers.getContractFactory("ARexAttestations"))
.deploy(admin.address, await token.getAddress());
Из командной строки тот же стек вместе с хуками допуска и срока годности развёртывается так:
npx hardhat node # terminal 1
npx hardhat run scripts/deploy-standard.js --network localhost # token, attestations, 2 hooks
npx hardhat run scripts/deploy-full-standard.js --network localhost # every hook in order + extensions
или с помощью Hardhat Ignition:
npx hardhat ignition deploy ignition/modules/ARexToken.js --network localhost \
--parameters '{"ARexTokenModule":{"baseURI":"ipfs://<cid>/{id}.json","allowPreHarvestTransfers":true}}'
Для тестовой сети или L2 в hardhat.config.js определены sepolia, polygon и arbitrum; URL RPC и PRIVATE_KEY задаются в .env (шаблон — в .env.example). ARexToken компилируется в 24,136 байт при optimizer.runs = 1, что укладывается в лимит EIP-170 в 24,576 байт.
3 · Роли
initialize(admin, baseURI) выдаёт все роли admin, что удобно для первого развёртывания и неверно для промышленной эксплуатации. Каждую операционную роль следует выдавать сервису или мультиподписи, которые её исполняют:
await token.grantRole(await token.ISSUER_ROLE(), issuer.address);
await token.grantRole(await token.TRANSFORMER_ROLE(), mill.address);
await token.grantRole(await token.STATUS_ROLE(), warehouse.address);
await token.grantRole(await token.ENFORCER_ROLE(), enforcer.address);
| Роль | Что разрешает | Рекомендуемый держатель |
|---|---|---|
DEFAULT_ADMIN_ROLE | выдача и отзыв любых ролей; setBaseURI | мультиподпись управления за таймлоком |
UPGRADER_ROLE | замена реализации | тот же таймлок |
HOOK_ADMIN_ROLE | registerHook, removeHook | тот же таймлок (изменения хуков становятся видны до применения) |
ENFORCER_ROLE | setFrozenTokens, forcedTransfer | мультиподпись принудительного исполнения (комплаенс и юристы) |
PAUSER_ROLE | pause, unpause | ключ реагирования на инциденты |
ISSUER_ROLE | createBatch | сервис первичного оформления партий |
STATUS_ROLE | setBatchStatus, setBatchMetadata | склад, инспектор или реестр |
TRANSFORMER_ROLE | перерабатывающие преобразования (изменение товара, единицы измерения или количества) | переработчики |
ATTEST_ADMIN_ROLE (в ARexAttestations) | setTrustedIssuer, отзыв любой аттестации | сертифицирующий орган или платформа |
Когда у каждой роли появился держатель, развёртывающая сторона отказывается от ненужных ей ролей с помощью renounceRole(role, deployer).
4 · Создание партии
Партия — это один идентификатор ERC-1155. Её ончейн-запись мала и фиксирована; всё описательное находится в документе метаданных, хеш которого зафиксирован ончейн (схема: standard/schema/batch-metadata.schema.json).
const metadata = { schemaVersion: "1.0.0", commodity: { scheme: "HS", code: "100199" }, unit: "KGM", quantity: 250000 };
const canonical = JSON.stringify(metadata); // RFC 8785 canonicalization in production
const metadataHash = ethers.keccak256(ethers.toUtf8Bytes(canonical));
const uri = "ipfs://<cid>/batch-1.json"; // wherever you stored `canonical`
const init = {
commodity: commodity("HS", "100199"), // wheat
unit: unit("KGM"),
producedAt: Math.floor(Date.parse("2026-03-18T00:00:00Z") / 1000), // 0 = not yet harvested
expiresAt: 0, // 0 = no expiry
originRef: ref("DDS-2026-AR-000123"), // a reference, never coordinates
metadataHash,
uri,
};
const id = await token.connect(issuer).createBatch.staticCall(init, 250_000, farmer.address, "0x");
await (await token.connect(issuer).createBatch(init, 250_000, farmer.address, "0x")).wait();
createBatch.staticCall возвращает id, который назначит транзакция; идентификаторы начинаются с 1 и растут на единицу. Токен отклоняет:
| Условие | Ошибка |
|---|---|
у вызывающего нет ISSUER_ROLE | AccessControlUnauthorizedAccount(account, role) |
to — нулевой адрес | ARexInvalidRecipient() |
quantity == 0 | ARexInvalidBatch("ZERO_QUANTITY") |
commodity == 0 или unit == 0 | ARexInvalidBatch("COMMODITY_REQUIRED") / ("UNIT_REQUIRED") |
обе даты заданы и expiresAt <= producedAt | ARexInvalidBatch("EXPIRY_BEFORE_PRODUCTION") |
токен приостановлен, получатель не допущен или хук отклоняет OP_MINT | EnforcedPause(), ERC7943CannotReceive(to), ARexHookRejected(hook, code) |
Соглашения по полям:
| Поле | Соглашение | Пример |
|---|---|---|
commodity | keccak256("<scheme>:<code>"), схема из HS, FAO, GTIN, UNSPSC или собственная | keccak256("HS:100199") — пшеница |
unit | код Рекомендации UN/CEFACT 20 в виде bytes32 | "KGM", "TNE", "LTR", "BX" |
producedAt | Unix-секунды; 0 для лота, урожай которого ещё не убран | форвардная продажа, дебиторская задолженность по урожаю |
expiresAt | Unix-секунды; 0 — без срока годности | скоропортящаяся продукция |
originRef | хеш ссылки на доказательства происхождения; никогда не координаты | ссылка на DDS по EUDR, идентификатор реестра |
metadataHash | keccak256 канонического (RFC 8785) документа метаданных | |
uri | где находится документ; следует делать его коротким, он хранится ончейн | ipfs://<cid>, GS1 Digital Link |
5 · Чтение партии
const batch = await token.batchOf(id); // reverts ARexBatchNotFound if unknown
await token.batchExists(id); // true / false, never reverts
await token["totalSupply(uint256)"](id); // units in circulation (overloaded name)
await token.parentsOf(id); // [] for an original lot
await token.uri(id); // batch URI, or the base URI if empty
await token.isExpired(id); // reference-implementation helper
totalSupply перегружена в ERC-1155 Supply (totalSupply() и totalSupply(uint256)), поэтому в ethers v6 её следует вызывать по сигнатуре. Batch.status — это 0 (ACTIVE), 255 (RETIRED) или определяемое эмитентом значение между ними.
6 · Перевод
Всегда следует сначала выполнять проверку. transferCheck воспроизводит конвейер переводов как view-функция и возвращает первый код отказа; 0x00…00 означает «разрешено». canTransfer — булева версия того же ответа по ERC-7943.
const code = await token.transferCheck(farmer.address, buyer.address, id, 10_000);
if (code !== ethers.ZeroHash) throw new Error(`transfer blocked: ${explain(code)}`);
await token.connect(farmer).safeTransferFrom(farmer.address, buyer.address, id, 10_000, "0x");
Порядок конвейера фиксирован, поэтому код показывает, какое правило не пройдено:
| Порядок | Проверка | Код, возвращаемый transferCheck | Откат транзакции |
|---|---|---|---|
| 1 | токен приостановлен | AREX:PAUSED | EnforcedPause() |
| 2 | отправитель допущен (каждый хук ELIGIBILITY) | AREX:SENDER_INELIGIBLE | ERC7943CannotSend(from) |
| 3 | получатель допущен | AREX:RECEIVER_INELIGIBLE | ERC7943CannotReceive(to) |
| 4 | партия существует | AREX:BATCH_NOT_FOUND | ARexBatchNotFound(id) |
| 5 | партия не погашена | AREX:BATCH_RETIRED | ARexBatchRetired(id) |
| 6 | amount <= balance − frozen | AREX:FROZEN | ERC7943InsufficientUnfrozenBalance(from, id, amount, unfrozen) |
| 7 | каждый хук CHECK в порядке регистрации | код хука | ARexHookRejected(hook, code) |
View-функция и транзакция вычисляют проверки в одном и том же порядке, поэтому код всегда указывает проверку, на которой транзакция откатывается первой.
На практике важны две детали. AREX:FROZEN возвращается и тогда, когда у отправителя просто недостаточно единиц, поскольку проверка выполняется по незамороженному балансу. Кроме того, transferCheck передаёт хукам пустые data; хук, читающий данные перевода (например, расчёт в DeliveryHook), может ответить иначе для реальной транзакции.
7 · Родословная: разделение, объединение, переработка
transform — единственный способ получить партию из партий. Он сжигает входы у from, эмитирует выходы на to, записывает parentsOf(output) = inputIds и генерирует одно событие BatchTransformed, которое соответствует EPCIS TransformationEvent. transform.staticCall возвращает новые id.
Переформирование партий сохраняет товар, единицу измерения и общее количество и не требует роли — только владения или одобрения ERC-1155. Разделение одного лота на два для покупателя:
const [lotA, lotB] = await token.connect(farmer).transform.staticCall(
farmer.address, buyer.address, [id], [400], [init, init], [250, 150], ref("SALE-42"));
await token.connect(farmer).transform(
farmer.address, buyer.address, [id], [400], [init, init], [250, 150], ref("SALE-42"));
Объединение двух лотов в один совместный лот для кооператива:
const [pooled] = await token.connect(buyer).transform.staticCall(
buyer.address, coop.address, [lotA, lotB], [250, 150], [init], [400], ref("POOL-7"));
await token.connect(buyer).transform(
buyer.address, coop.address, [lotA, lotB], [250, 150], [init], [400], ref("POOL-7"));
Переработка меняет товар, единицу измерения или количество, поэтому требует TRANSFORMER_ROLE в дополнение к одобрению. Мельница превращает 1,000 кг пшеницы фермера в 750 кг муки:
const now = (await ethers.provider.getBlock("latest")).timestamp;
const flourInit = { ...init, commodity: commodity("HS", "110100"), producedAt: now };
await token.connect(farmer).setApprovalForAll(mill.address, true);
const [flour] = await token.connect(mill).transform.staticCall(
farmer.address, mill.address, [id], [1_000], [flourInit], [750], ref("MILL-RUN-7"));
await token.connect(mill).transform(
farmer.address, mill.address, [id], [1_000], [flourInit], [750], ref("MILL-RUN-7"));
| Правило | Чем обеспечивается |
|---|---|
вызывающий — from или одобрен from | transform |
| каждый вход существует, не погашен и имеет достаточный незамороженный баланс | _update |
| переформирование: тот же товар и единица измерения, Σ входов = Σ выходов | роль не нужна |
| всё остальное | TRANSFORMER_ROLE, иначе AccessControlUnauthorizedAccount |
| хуки видят каждое звено | OP_TRANSFORM для сжиганий и эмиссий |
Родословная, построенная в этом руководстве:
flowchart LR
B1["#1 пшеница 250,000 кг<br/>фермер"] -->|"разделение · SALE-42"| B2["#2 пшеница 250"]
B1 -->|"разделение · SALE-42"| B3["#3 пшеница 150"]
B2 -->|"объединение · POOL-7"| B4["#4 пшеница 400<br/>кооператив"]
B3 -->|"объединение · POOL-7"| B4
B1 -->|"переработка · MILL-RUN-7"| B5["#5 мука 750<br/>мельница"]8 · Статус и метаданные
Статус меняет только STATUS_ROLE; владение единицами ничего не даёт. Значения 1..254 определяются самостоятельно; их смысл следует публиковать в документе метаданных (status.table). Обновление метаданных заменяет хеш и URI и генерирует BatchMetadataUpdated, поэтому история документов остаётся проверяемой.
await token.connect(warehouse).setBatchStatus(flour, 1, ref("IN_STORAGE")); // 1..254 are yours
const labReport = { ...metadata, lab: { moisture: 14.2 } };
await token.connect(warehouse).setBatchMetadata(
flour, ethers.keccak256(ethers.toUtf8Bytes(JSON.stringify(labReport))), "ipfs://<cid>/flour-lab.json");
9 · Хуки
Хуки — независимые контракты, которые токен вызывает в порядке регистрации. Здесь первым выполняется хук допуска, затем хук срока годности, затем правило о том, что кофе требует аттестованного заявления о должной осмотрительности по EUDR до того, как его можно продать.
const eligibility = await (await ethers.getContractFactory("EligibilityHook")).deploy(admin.address);
const expiry = await (await ethers.getContractFactory("ExpiryHook")).deploy(true); // allow pre-harvest
const attestationHook = await (await ethers.getContractFactory("AttestationHook"))
.deploy(admin.address, await attestations.getAddress());
const COFFEE = commodity("HS", "090111");
const EUDR_DDS = ref("AREX:EUDR:DDS");
await attestationHook.setRequirement(COFFEE, EUDR_DDS); // coffee needs a due-diligence statement
await token.registerHook(await eligibility.getAddress()); // evaluated first
await token.registerHook(await expiry.getAddress());
await token.registerHook(await attestationHook.getAddress()); // evaluated last
await token.getHooks(); // the ordered list
| Хук | Флаги | Конструктор | Настраивается через |
|---|---|---|---|
EligibilityHook | CHECK, ELIGIBILITY | (admin) | setAllowlistEnabled, setAllowlisted, setBlocklisted, setSanctioned |
ExpiryHook | CHECK | (allowPreHarvestTransfers) | — (читает запись партии) |
AttestationHook | CHECK | (owner, attestationRegistry) | setRequirement(commodity, topic) |
PerishabilityHook | CHECK | (admin) | setCommodityDecay, setBatchDecay |
SeasonalHook | CHECK | (admin) | addWindow(commodity, op, start, end), clearWindows |
DeliveryHook | CHECK, EXECUTED | (admin) | schedule, pickUp, markInTransit, recordReading, confirmDelivered, dispute, resolve |
TraceabilityHook | EXECUTED | (admin, persist) | recordEvent (RECORDER_ROLE) |
Регистрация отклоняет EOA, контракты без поддержки ERC-165 для IARexHook и хуки, у которых флаги равны 0. Дешёвые хуки, часто выдающие отказ, следует ставить первыми: отказ останавливает конвейер. Написание собственного хука: docs/HOOK-DEVELOPMENT.md.
10 · Аттестации
Аттестация — это (batch, topic) → (issuer, reference, issuedAt, expiresAt, revoked), действительная лишь пока её эмитенту доверяют по данной теме. При действующем требовании из шага 9 лот кофе не может перемещаться, пока заявление не аттестовано:
const coffee = await token.connect(issuer).createBatch.staticCall({ ...init, commodity: COFFEE }, 60, farmer.address, "0x");
await token.connect(issuer).createBatch({ ...init, commodity: COFFEE }, 60, farmer.address, "0x");
explain(await token.transferCheck(farmer.address, buyer.address, coffee, 1)); // "ATTESTATION_MISSING"
await attestations.setTrustedIssuer(EUDR_DDS, certifier.address, true); // ATTEST_ADMIN_ROLE
await attestations.connect(certifier).attest(coffee, EUDR_DDS, ref("DDS-2026-AR-000123"), 0);
await attestations.isAttested(coffee, EUDR_DDS); // true
explain(await token.transferCheck(farmer.address, buyer.address, coffee, 1)); // "OK"
isAttested ложен, когда аттестации нет, когда она отозвана, когда срок expiresAt прошёл или когда эмитенту больше не доверяют по данной теме. Отозвать может эмитент или ATTEST_ADMIN_ROLE. Темы — произвольные значения bytes32; следует использовать keccak256("<NAMESPACE>:<NAME>"), например AREX:EUDR:DDS или AREX:CERT:ORGANIC_EU.
11 · Санкции и допуск
EligibilityHook отвечает на canSend и canReceive для токена. Санкция записывает свой источник и необязательный срок действия, поэтому санкционная запись может утрачивать силу автоматически.
await eligibility.setSanctioned(buyer.address, true, ethers.encodeBytes32String("OFAC-SDN"), 0);
explain(await token.transferCheck(farmer.address, buyer.address, id, 1)); // "RECEIVER_INELIGIBLE"
await eligibility.setSanctioned(buyer.address, false, ethers.encodeBytes32String("OFAC-SDN"), 0);
12 · Принудительное исполнение
Заморозка и принудительные переводы — это поверхность принудительного исполнения ERC-7943, предназначенная для обременений, судебных постановлений и арестов. Заморозка может превышать текущий баланс и тогда распространяется и на будущие зачисления. Принудительный перевод пропускает допуск и проверку замороженного, работает при приостановленном токене, уменьшает замороженное количество до остатка и всё равно запускает хуки с OP_FORCED_TRANSFER.
await token.connect(enforcer).setFrozenTokens(buyer.address, id, 6_000); // may exceed the balance
await token.getFrozenTokens(buyer.address, id); // 6_000
explain(await token.transferCheck(buyer.address, farmer.address, id, 5_000)); // "FROZEN"
await token.pause(); // PAUSER_ROLE
await token.connect(enforcer).forcedTransfer(buyer.address, custodian.address, id, 6_000); // works while paused
await token.unpause();
Процедуру, по которой действует ENFORCER_ROLE (судебное постановление, договор залога, уведомление о санкциях), следует документировать, а саму роль держать за мультиподписью. Каждое действие генерирует Frozen или ForcedTransfer.
13 · Погашение
RETIRED (255) терминален: никаких переводов, эмиссии и преобразований, и пути назад нет. Сжигание остаётся возможным, чтобы доставленные или уничтоженные товары выбывали из предложения.
await token.connect(warehouse).setBatchStatus(flour, 255, ref("DELIVERED")); // RETIRED is terminal
explain(await token.transferCheck(mill.address, buyer.address, flour, 1)); // "BATCH_RETIRED"
await token.connect(mill).burn(mill.address, flour, 750); // burning still works
14 · Индексация
Граф родословной — это список событий BatchTransformed; ничего не нужно восстанавливать из отдельных переводов.
const transforms = await token.queryFilter(token.filters.BatchTransformed());
const edges = transforms.flatMap((e) =>
e.args.inputIds.flatMap((input) => e.args.outputIds.map((output) => [input, output, e.args.processRef])));
// edges: [1 → 2], [1 → 3], [2 → 4], [3 → 4], [1 → 5]
| Событие | Кем генерируется | Назначение |
|---|---|---|
BatchCreated(id, issuer, commodity, unit, quantity, to) | createBatch | новый лот; EPCIS ObjectEvent ADD |
BatchTransformed(operator, inputIds, inputAmounts, outputIds, outputAmounts, processRef) | transform | ребро родословной; EPCIS TransformationEvent |
BatchStatusChanged(id, previousStatus, newStatus, reason) | setBatchStatus | жизненный цикл |
BatchMetadataUpdated(id, metadataHash, uri) | setBatchMetadata | история документов |
TransferSingle, TransferBatch | каждое изменение баланса | владения; EPCIS ObjectEvent OBSERVE |
Frozen(account, tokenId, amount), ForcedTransfer(from, to, tokenId, amount) | принудительное исполнение | обременения, аресты |
HookRegistered(hook, flags, position), HookRemoved(hook) | администратор хуков | изменения правил |
Attested, AttestationRevoked, TrustedIssuerUpdated | ARexAttestations | доказательства |
15 · Обновление
Хранилище использует пространство имён ERC-7201 (erc7201:arex.storage.token); поля следует только добавлять в TokenStorage, никогда не переупорядочивая и не удаляя их. Перед каждым обновлением необходимо выполнять проверку; плагин отклоняет раскладки, которые повредили бы хранилище.
await upgrades.validateUpgrade(await token.getAddress(), ARexToken, { kind: "uups" });
await upgrades.upgradeProxy(await token.getAddress(), ARexToken); // UPGRADER_ROLE
В промышленной эксплуатации UPGRADER_ROLE находится за мультиподписью и таймлоком, а обновление предварительно репетируется на форке целевой сети.
Тестирование интеграции
npx hardhat test test/standard/ImplementationGuide.test.js # this chapter, executed
npx hardhat test test/standard/ARexToken.test.js # token behaviour and conformance
npx hardhat run scripts/smoke-standard.js # narrated walkthrough of one lot
npx hardhat run scripts/gas-report-standard.js # gas with 0, 1 and 2 hooks, and per hook
test/standard/helpers.js экспортирует deployStack(), batchInit(), константы OP, HOOK, STATUS и CODES и supply() для собственных тестов.
Переход в промышленную эксплуатацию: контрольный список
- Воспроизводимая сборка (
npm ci), CI успешен, идентификаторы интерфейсов совпадают со спецификацией. - Роли перераспределены, как на шаге 3; развёртывающая сторона отказалась от своих ролей.
- Хуки проверены аудитом, порядок задокументирован,
getHooks()опубликован. - Версия схемы метаданных и таблица статусов опубликованы;
originRefи данные с ограниченным доступом свободны от персональных данных. - Правовые инструменты указаны в метаданных; классификация предложения согласована с юристами.
- Аудит токена, реестра аттестаций и каждого зарегистрированного хука третьей стороной (пакет:
docs/AUDIT-READINESS.md). - Обновление отрепетировано на форке с
validateUpgrade.