Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/agent/src/sync-verify-worker-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,8 @@ function processDurableBatch(
verifiedMetaIndexes: [],
verifiedGraphScopedDataGraphs: [],
droppedSyncControlTriples: 0,
droppedNonIriSubjectTriples: 0,
consumedUnpersistedMetaTriples: 0,
verifiedPrivateOnlyResponses: 0,
totalFetchedDataQuads,
totalFetchedMetaQuads,
Expand All @@ -330,6 +332,8 @@ function processDurableBatch(
verifiedMetaIndexes: [],
verifiedGraphScopedDataGraphs: [],
droppedSyncControlTriples: 0,
droppedNonIriSubjectTriples: 0,
consumedUnpersistedMetaTriples: 0,
verifiedPrivateOnlyResponses: 0,
totalFetchedDataQuads,
totalFetchedMetaQuads,
Expand Down Expand Up @@ -384,6 +388,11 @@ function processDurableBatch(
verifiedMetaIndexes: verifiedSelection.metaIndexes,
verifiedGraphScopedDataGraphs: verifiedSelection.verifiedGraphScopedDataGraphs,
droppedSyncControlTriples: verifiedSelection.droppedSyncControlTriples,
droppedNonIriSubjectTriples: verifiedSelection.droppedNonIriSubjectTriples,
// Transport the verifier-owned aggregate (#1921) — do NOT recompute the sum
// here. The early-return branches above (empty page / data-without-meta)
// bypass selection and set consumedUnpersistedMetaTriples: 0 explicitly.
consumedUnpersistedMetaTriples: verifiedSelection.consumedUnpersistedMetaTriples,
verifiedPrivateOnlyResponses: verifiedFullyPrivateResponse ? 1 : 0,
totalFetchedDataQuads,
totalFetchedMetaQuads,
Expand Down Expand Up @@ -423,6 +432,8 @@ export function processDurableBatchForWire(
verifiedMetaIndexes,
verifiedGraphScopedDataGraphs,
droppedSyncControlTriples,
droppedNonIriSubjectTriples,
consumedUnpersistedMetaTriples,
verifiedPrivateOnlyResponses,
totalFetchedDataQuads,
totalFetchedMetaQuads,
Expand All @@ -439,6 +450,8 @@ export function processDurableBatchForWire(
verifiedMetaIndexes,
verifiedGraphScopedDataGraphs,
droppedSyncControlTriples,
droppedNonIriSubjectTriples,
consumedUnpersistedMetaTriples,
verifiedPrivateOnlyResponses,
totalFetchedDataQuads,
totalFetchedMetaQuads,
Expand Down
17 changes: 16 additions & 1 deletion packages/agent/src/sync-verify-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,23 @@ export interface DurableBatchProcessResult {
verifiedData: Quad[];
verifiedMeta: Quad[];
verifiedGraphScopedDataGraphs: string[];
/** Metadata controls deliberately consumed after failing authentication. */
/** Metadata controls deliberately consumed after failing authentication (diagnostic). */
droppedSyncControlTriples: number;
/**
* Non-IRI (blank-node/literal) `_meta` subject rows deliberately dropped at
* ingest (#1921) — a verifier-side diagnostic count.
*/
droppedNonIriSubjectTriples: number;
/**
* Reason-agnostic aggregate of meta rows the verifier deliberately CONSUMED
* but did not persist (unverified sync controls + non-IRI subjects). This is
* the single count the requester uses to decide whether a fully-discarded
* metadata-only page still advances the meta checkpoint (rather than pinning
* durable sync on the same page). Keeping the per-reason counts above as
* diagnostics only keeps checkpoint orchestration decoupled from verifier
* discard policy.
*/
consumedUnpersistedMetaTriples: number;
/** Clean batches containing verified V2 assets with no public assertion triples. */
verifiedPrivateOnlyResponses: number;
totalFetchedDataQuads: number;
Expand Down
121 changes: 113 additions & 8 deletions packages/agent/src/sync/durable-integrity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,19 @@ export interface DurableIntegritySelection {
dataIndexes: number[];
metaIndexes: number[];
rejected: number;
/** Unauthenticated cursor/routing rows deliberately consumed but not persisted. */
/** Unauthenticated cursor/routing rows deliberately consumed but not persisted (diagnostic). */
droppedSyncControlTriples: number;
/** Non-IRI (blank-node/literal) `_meta` subject rows dropped at peer ingest (#1921) (diagnostic). */
droppedNonIriSubjectTriples: number;
/**
* Reason-agnostic aggregate of `_meta` rows deliberately consumed but NOT
* persisted (= droppedSyncControlTriples + droppedNonIriSubjectTriples). Owned
* here by the verifier that classifies the drops (#1921): the worker transports
* it and the requester uses it as the single meta-checkpoint-advance signal, so
* checkpoint policy never has to enumerate verifier discard reasons. The two
* per-reason fields above stay as diagnostics.
*/
consumedUnpersistedMetaTriples: number;
/** Verified V2 assets whose exact public assertion graph is intentionally empty. */
verifiedZeroPublicAssets: number;
/** Exact assertion graphs whose V2 descriptors and fetched payload verified. */
Expand Down Expand Up @@ -171,7 +182,13 @@ export function planBoundedGraphScopedDurableBatch(
|| metaQuads.length === 0
) return null;

const metadata = indexIntegrityMetadata(dataQuads, metaQuads);
// #1921 — verification must never see non-IRI `_meta` subjects: a peer's
// `_:bad dkg:partOf "<valid-ual>"` row would otherwise be scanned by
// readIntegrityMetadata and falsely invalidate the valid graph-scoped UAL.
// Sanitize before indexing. The bounded planner returns only data offsets,
// so dropping non-IRI meta rows here is loss-free.
const iriMetaQuads = metaQuads.filter((quad) => isIriMetaSubject(quad.subject));
Comment thread
Jurij89 marked this conversation as resolved.
const metadata = indexIntegrityMetadata(dataQuads, iriMetaQuads);
const parsed = readIntegrityMetadata(metadata, false);
if (
parsed.fatalUnscopedFailure
Expand Down Expand Up @@ -365,6 +382,8 @@ export function selectVerifiedDurableSyncQuads(
metaIndexes: [],
rejected: 1,
droppedSyncControlTriples: 0,
droppedNonIriSubjectTriples: 0,
consumedUnpersistedMetaTriples: 0,
verifiedZeroPublicAssets: 0,
verifiedGraphScopedDataGraphs: [],
logs,
Expand All @@ -375,13 +394,25 @@ export function selectVerifiedDurableSyncQuads(
metaIndexes: [],
rejected: 0,
droppedSyncControlTriples: 0,
droppedNonIriSubjectTriples: 0,
consumedUnpersistedMetaTriples: 0,
verifiedZeroPublicAssets: 0,
verifiedGraphScopedDataGraphs: [],
logs,
};
}

const metadata = indexIntegrityMetadata(dataQuads, metaQuads);
// #1921 — sanitize the verification inputs ONCE at this boundary so a non-IRI
// `_meta` subject can neither authenticate data (it never becomes a candidate)
// NOR poison verification (the readIntegrityMetadata PART_OF scan and
// verifyLegacyCandidates' raw scan never see it, so a `_:bad dkg:partOf
// "<valid-ual>"` row cannot falsely invalidate a valid batch). Admission below
// deliberately runs on the ORIGINAL metaQuads: the selectors still drop+count
// non-IRI rows (persist-drop + meta-cursor advance) and index into the
// original array. The verification outcome is subject-keyed, so no positional
// remap is needed across the sanitized/original split.
const iriMetaQuads = metaQuads.filter((quad) => isIriMetaSubject(quad.subject));
const metadata = indexIntegrityMetadata(dataQuads, iriMetaQuads);
if (metadata.merkleSubjects.size === 0 && metadata.markerSubjects.size === 0) {
if (!acceptUnverified && dataQuads.length > 0) {
logs.push({
Expand All @@ -393,6 +424,8 @@ export function selectVerifiedDurableSyncQuads(
metaIndexes: [],
rejected: 1,
droppedSyncControlTriples: 0,
droppedNonIriSubjectTriples: 0,
consumedUnpersistedMetaTriples: 0,
verifiedZeroPublicAssets: 0,
verifiedGraphScopedDataGraphs: [],
logs,
Expand All @@ -406,11 +439,14 @@ export function selectVerifiedDurableSyncQuads(
new Set(),
);
logDroppedSyncControls(logs, selectedMetadata.droppedControls);
logDroppedNonIriMetaSubjects(logs, selectedMetadata.droppedNonIriSubjectTriples);
return {
dataIndexes: allIndexes(dataQuads),
metaIndexes: selectedMetadata.indexes,
rejected: 0,
droppedSyncControlTriples: selectedMetadata.droppedControls,
droppedNonIriSubjectTriples: selectedMetadata.droppedNonIriSubjectTriples,
consumedUnpersistedMetaTriples: selectedMetadata.droppedControls + selectedMetadata.droppedNonIriSubjectTriples,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: Stop hand-maintaining the consumed metadata aggregate in every return branch

What's wrong
The PR introduces a useful aggregate boundary, but the implementation keeps the invariant as repeated arithmetic across several branches. That preserves incidental complexity inside the verifier and makes the next discard reason or branch edit more fragile than necessary.

Example
The invariant is currently repeated as selectedMetadata.droppedControls + selectedMetadata.droppedNonIriSubjectTriples at lines 449, 1075, and 1116, while early returns manually spell out all-zero counters. Adding another consumed-but-unpersisted reason means every return branch has to be audited again.

Suggested direction
Have the metadata-selection helper return a drop-count object with a derived consumedUnpersistedMetaTriples, or build DurableIntegritySelection through a small constructor. The caller should pass counts, not restate the aggregate formula branch by branch.

For Agents
In packages/agent/src/sync/durable-integrity.ts, introduce a DurableMetaDropCounts/MetadataAdmissionSelection helper or result constructor that computes consumedUnpersistedMetaTriples exactly once. Preserve the existing public fields and requester behavior; the existing aggregate tests should prove the helper is wired through every branch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring to #1943 — this is the same centralization theme it already tracks. #1943 covers a prepareDurableMeta / DurableMetaDropCounts helper (or a DurableIntegritySelection constructor) that computes consumedUnpersistedMetaTriples exactly ONCE and threads it through every return branch, so a new discard reason won't require auditing each branch.

The aggregate-OWNERSHIP move (the verifier owns the field, single requester signal) shipped in this PR (b7710dc). Computing-it-once across the 7 return sites is the deferred helper refactor: it reworks the durable-meta verification INTAKE on the security-critical path, so it belongs in #1943 with its own design + mutation-proof + review rather than widening this security fix. Tracked in #1943; leaving this thread open.

verifiedZeroPublicAssets: 0,
verifiedGraphScopedDataGraphs: [],
logs,
Expand All @@ -427,7 +463,7 @@ export function selectVerifiedDurableSyncQuads(
);
const legacy = verifyLegacyCandidates(
dataQuads,
metaQuads,
iriMetaQuads,
metadata,
parsed.candidates,
acceptUnverified,
Expand Down Expand Up @@ -483,6 +519,16 @@ function indexIntegrityMetadata(

const merkleSubjects = new Set<string>();
const markerSubjects = new Set<string>();
// #1921 PRECONDITION: callers MUST pass IRI-sanitized `_meta` quads. Both
// current callers do — selectVerifiedDurableSyncQuads and
// planBoundedGraphScopedDurableBatch filter non-IRI subjects at their boundary
// before indexing — and any NEW caller MUST too. This function no longer
// filters internally, so a non-IRI subject reaching here would re-enter
// candidacy AND the metaBySubject-based verification scans
// (readIntegrityMetadata's PART_OF scan, parseGraphScopedDescriptor), letting a
// peer's blank-node/literal subject authenticate data OR poison a valid batch.
// Admission (the selectors) deliberately runs on the ORIGINAL metaQuads and is
// where non-IRI rows are dropped + counted.
for (const quad of metaQuads) {
if (quad.predicate === MERKLE_ROOT) {
merkleSubjects.add(quad.subject);
Expand Down Expand Up @@ -1019,11 +1065,14 @@ function selectVerifiedQuads(
outcome.kaToKc,
);
logDroppedSyncControls(logs, selectedMetadata.droppedControls);
logDroppedNonIriMetaSubjects(logs, selectedMetadata.droppedNonIriSubjectTriples);
return {
dataIndexes: allIndexes(dataQuads),
metaIndexes: selectedMetadata.indexes,
rejected: 0,
droppedSyncControlTriples: selectedMetadata.droppedControls,
droppedNonIriSubjectTriples: selectedMetadata.droppedNonIriSubjectTriples,
consumedUnpersistedMetaTriples: selectedMetadata.droppedControls + selectedMetadata.droppedNonIriSubjectTriples,
verifiedZeroPublicAssets: outcome.verifiedZeroPublicAssets,
verifiedGraphScopedDataGraphs,
logs,
Expand All @@ -1035,6 +1084,8 @@ function selectVerifiedQuads(
metaIndexes: [],
rejected,
droppedSyncControlTriples: 0,
droppedNonIriSubjectTriples: 0,
consumedUnpersistedMetaTriples: 0,
verifiedZeroPublicAssets: outcome.verifiedZeroPublicAssets,
// Keep this list aligned with the selected data + metadata indexes.
// A fatal batch deliberately selects neither. Returning the names of
Expand All @@ -1054,12 +1105,15 @@ function selectVerifiedQuads(
outcome.authenticatedMetadataUals,
);
logDroppedSyncControls(logs, selectedMetadata.droppedControls);
logDroppedNonIriMetaSubjects(logs, selectedMetadata.droppedNonIriSubjectTriples);

return {
dataIndexes,
metaIndexes: selectedMetadata.indexes,
rejected,
droppedSyncControlTriples: selectedMetadata.droppedControls,
droppedNonIriSubjectTriples: selectedMetadata.droppedNonIriSubjectTriples,
consumedUnpersistedMetaTriples: selectedMetadata.droppedControls + selectedMetadata.droppedNonIriSubjectTriples,
verifiedZeroPublicAssets: outcome.verifiedZeroPublicAssets,
verifiedGraphScopedDataGraphs,
logs,
Expand All @@ -1072,11 +1126,23 @@ function selectAdmittedMetadataIndexes(
admittedMetadataUals: ReadonlySet<string>,
kaToKc: ReadonlyMap<string, string>,
authenticatedMetadataUals: ReadonlySet<string>,
): { indexes: number[]; droppedControls: number } {
): { indexes: number[]; droppedControls: number; droppedNonIriSubjectTriples: number } {
const indexes: number[] = [];
let droppedControls = 0;
let droppedNonIriSubjectTriples = 0;
for (let index = 0; index < metaQuads.length; index++) {
const quad = metaQuads[index]!;
// #1921 — admission runs on the ORIGINAL metaQuads (verification already ran
// on the IRI-sanitized set at the boundary). A non-IRI descriptive row would
// otherwise reach the descriptive fall-through below and be persisted, so
// drop + count it here — that keeps it out of the store AND feeds the
// meta-cursor consumed-row count (checkpoint advance). It could not reach the
// merkle/marker branch regardless: the boundary sanitize keeps non-IRI
// subjects out of `merkleSubjects`/`markerSubjects`.
if (!isIriMetaSubject(quad.subject)) {
Comment thread
Jurij89 marked this conversation as resolved.
droppedNonIriSubjectTriples += 1;
continue;
}
if (
metadata.merkleSubjects.has(quad.subject)
|| metadata.markerSubjects.has(quad.subject)
Expand Down Expand Up @@ -1130,19 +1196,28 @@ function selectAdmittedMetadataIndexes(
}
indexes.push(index);
}
return { indexes, droppedControls };
return { indexes, droppedControls, droppedNonIriSubjectTriples };
}

function selectSystemOverrideMetadataIndexes(
metaQuads: readonly Quad[],
metadata: IntegrityMetadataIndex,
authenticatedMetadataUals: ReadonlySet<string>,
kaToKc: ReadonlyMap<string, string>,
): { indexes: number[]; droppedControls: number } {
): { indexes: number[]; droppedControls: number; droppedNonIriSubjectTriples: number } {
const indexes: number[] = [];
let droppedControls = 0;
let droppedNonIriSubjectTriples = 0;
for (let index = 0; index < metaQuads.length; index++) {
const quad = metaQuads[index]!;
// #1921 — reject a non-IRI durable `_meta` subject at ingest. This terminal
// system-CG selector admits every non-control row, so without this guard a
// blank-node subject bearing ANY descriptive or integrity predicate
// (e.g. `dkg:merkleRoot`) would be persisted and later served back.
if (!isIriMetaSubject(quad.subject)) {
droppedNonIriSubjectTriples += 1;
continue;
}
if (
DURABLE_SYNC_CONTROL_PREDICATE_SET.has(quad.predicate)
&& !isGraphSealDescriptiveVersion(quad, metadata)
Expand All @@ -1158,7 +1233,7 @@ function selectSystemOverrideMetadataIndexes(
}
indexes.push(index);
}
return { indexes, droppedControls };
return { indexes, droppedControls, droppedNonIriSubjectTriples };
}

/**
Expand Down Expand Up @@ -1247,6 +1322,17 @@ function logDroppedSyncControls(
});
}

function logDroppedNonIriMetaSubjects(
logs: DurableIntegrityLogEntry[],
dropped: number,
): void {
if (dropped === 0) return;
logs.push({
level: 'warn',
message: `Dropped ${dropped} non-IRI durable _meta subject triple(s) from peer ingest (#1921)`,
});
}

function isDetachedLegacyProjectionGraph(graph: string): boolean {
if (!graph.startsWith('did:dkg:context-graph:')) return false;
return graph.endsWith('/_catalog')
Expand Down Expand Up @@ -1394,6 +1480,25 @@ function stripLiteral(raw: string): string {
return match ? match[1]! : raw;
}

/**
* A durable `_meta` subject must be an IRI. Conforming writers only emit IRI
* subjects (metadata generators build deterministic UALs; the publisher rejects
* blank nodes; SWM writers skolemize before storage). A blank-node (`_:…`) or
* literal (`"…"`) subject is only reachable via unverified peer-ingest and has
* no trustworthy, stable identity, so it is dropped at ingest (#1921) rather
* than persisted. Mirrors the responder's `isIriTerm` (graph-plan.ts) so ingest
* and read agree on the contract.
*/
function isIriMetaSubject(term: string): boolean {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: Centralize the durable metadata term classifier instead of mirroring it by comment

What's wrong
This PR makes the IRI-only durable _meta invariant span ingest and read paths, but implements the same string-prefix classifier in two private places. That is a boundary-cleanliness problem: future changes to RDF term encoding, skolemization, or literal handling now have to update two large files in lockstep, and reviewers have to remember that these private helpers are semantically coupled.

Example
isIriMetaSubject('') returns false, while the responder's private isIriTerm('') returns true. Even if an empty term is not expected, this already shows the two copies can drift because there is no single term-boundary helper.

Suggested direction
Extract a canonical isIriTerm/isDurableMetaIriSubject helper in the layer that owns the string-encoded Quad contract, then import it from both ingest and responder code. The important invariant should be shared by code, not by a comment pointing at a private function.

For Agents
Move RDF string-term classification into a shared sync/storage utility, use it from durable-integrity and graph-plan, preserve the current blank-node/literal handling, and cover the shared helper with small tests for IRI, blank-node, literal, and empty-string cases.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring as a tracked follow-up: #1940.

The two helpers share the same _: / " prefix contract; the empty-string divergence you flagged (isIriMetaSubject('') → false vs the responder's isIriTerm('') → true) is a deliberate semantic rule, not drift: the ingest guard fails closed on a malformed empty _meta subject, while the responder read path never sees an empty term post-ingest. Unifying the two would change the responder read-path's empty-string handling and warrants its own review/tests, so I filed #1940 to track extracting a single canonical classifier rather than widen #1921's scope.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Suggestion: Clarify the RDF term boundary instead of calling this a full IRI check

Why it matters
The helper is now part of the durable ingest contract, so an imprecise name and duplicated implementation make future boundary work easy to misread or accidentally diverge.

Suggestion
Either extract/reuse a shared isNamedNodeTermString-style helper with the responder, or rename/comment this helper to match the actual predicate. If durable _meta truly requires validated IRIs, use the canonical IRI validator at this boundary instead of a prefix-only test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring the shared-classifier extraction / naming to #1940 (durable-meta IRI term-classifier unification across ingest + responder). The prefix-only isIriMetaSubject predicate is intentional here: it matches the responder's isIriTerm contract (_:/" prefixes), and the empty-string divergence is a deliberate fail-closed hardening at ingest. Extracting a canonical shared helper / using a full IRI validator at this boundary is a boundary-cleanliness change that touches the responder read path too, so it deserves its own review + tests rather than widening this security PR. Tracked in #1940.

// Defensive on `term`: this runs at the verification-input boundary filters
// (selectVerifiedDurableSyncQuads and planBoundedGraphScopedDurableBatch, both
// on RAW fetched meta) and in the admission selectors. A conforming quad always
// carries a non-empty string subject; tolerate a malformed one (treat as
// non-IRI → drop) instead of throwing.
return typeof term === 'string' && term.length > 0
&& !term.startsWith('_:') && !term.startsWith('"');
}

function findLegacyRootOwner(subject: string, roots: ReadonlySet<string>): string | undefined {
for (const root of roots) {
if (subject === root || subject.startsWith(`${root}${SKOLEM_SUFFIX}`)) return root;
Expand Down
22 changes: 18 additions & 4 deletions packages/agent/src/sync/requester/durable-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,13 @@ interface DurableSyncContext {
verifiedData: Quad[];
verifiedMeta: Quad[];
verifiedGraphScopedDataGraphs?: string[];
droppedSyncControlTriples?: number;
/**
* Worker-owned aggregate of meta rows deliberately consumed but not
* persisted. REQUIRED (#1921): it is the single checkpoint-advance signal,
* so every producer must set it — an optional field silently reading 0
* would let the meta cursor pin with no type error.
*/
consumedUnpersistedMetaTriples: number;
totalFetchedDataQuads: number;
totalFetchedMetaQuads: number;
rejectedKcs: number;
Expand Down Expand Up @@ -472,12 +478,20 @@ export async function runDurableSync(
};

const metadataOnlyResponse = processed.metaOnlyResponses > 0;
const droppedSyncControlTriples = processed.droppedSyncControlTriples ?? 0;
// The worker reports, as ONE reason-agnostic count, how many fetched meta
// rows the verifier deliberately consumed but did NOT persist — unverified
// sync controls plus non-IRI `_meta` subjects (#1921). A metadata-only page
// discarded ENTIRELY this way carries no verifiedMeta, so the meta cursor
// must still advance or durable sync pins on the same page. Depending on the
// aggregate (not per-reason counters) keeps checkpoint orchestration
// decoupled from verifier discard policy; the per-reason counts remain as
// verifier-side diagnostics only.
const consumedUnpersistedMetaTriples = processed.consumedUnpersistedMetaTriples;
const discardedOnlyMetadataResponse = metadataOnlyResponse
&& processed.verifiedData.length === 0
&& processed.verifiedMeta.length === 0
&& droppedSyncControlTriples > 0
&& droppedSyncControlTriples === processed.totalFetchedMetaQuads;
&& consumedUnpersistedMetaTriples > 0
&& consumedUnpersistedMetaTriples === processed.totalFetchedMetaQuads;
const updateMetaCheckpoint = batchVerifiedCleanly
&& processed.dataRejectedMissingMeta === 0
&& (
Expand Down
Loading
Loading