Skip to content

fix: deterministic source-import missing detection and denial-guard supersession - #154

Open
Coding-Dev-Tools wants to merge 6 commits into
mainfrom
fix/source-import-missing-and-denial-guard
Open

fix: deterministic source-import missing detection and denial-guard supersession#154
Coding-Dev-Tools wants to merge 6 commits into
mainfrom
fix/source-import-missing-and-denial-guard

Conversation

@Coding-Dev-Tools

Copy link
Copy Markdown
Owner

Summary

Two failing offline-gate tests, two latent defects behind them, and two pre-existing flaky tests, all fixed and verified:

  • mark_source_import_items_missing() gains an explicit source_keys path. A complete scan now marks exactly the planned-missing rows instead of relying on the last_seen_at < run_started heuristic, which a renamed file defeated: rename stamps last_seen_at=now, so a delete on the next run never matched and the row stayed renamed forever. The documents importer shares ObsidianImporter.import_scan and inherits the fix.
  • _clear_superseded_denial() supersedes a billing denial on persisted-state content change, not wall-clock comparison. Equal-timestamp entitlements made the strict > stick forever after a valid reconnect (the failing test_newer_active_session_clears_the_process_denial_guard), and a naive >= would let a pre-denial record saved in the same coarse clock tick resurrect grants. cloud_session.saved_session_digest() fingerprints the session bytes so write order is observed without exposing credentials. Verified both directions: pre-denial session → fail-closed; rewritten reconnect → supersedes.
  • Deflakes: ULID same-millisecond ordering randomized the repair-cursor sweep count (19% failure over 100 runs); archived_at == valid_from landed the historical as_of midpoint exactly on valid_to, excluded by the half-open temporal predicate (35% over 20 runs). Both now deterministic (10/10 runs each).

Test plan

  • Full offline suite green (python -m pytest tests/ -q, exit 0)
  • ruff check ., python scripts/check_commercial_manifest.py, pyright clean
  • Eval gates unchanged: harness (sample + codemem, recall@5 1.000), ablation, reinforcement, adversarial security, grounded (1.000)
  • Previously flaky consolidate tests: 10/10 deterministic passes
  • Denial-guard semantics probe: fail-closed pre-denial, superseding reconnect

…upersession

Two failing offline-gate tests and two latent defects:

- mark_source_import_items_missing() gains an explicit source_keys path: a
  complete scan now marks exactly the planned-missing rows instead of relying
  on the last_seen_at < run_started heuristic, which a renamed file defeated
  (rename stamps last_seen_at=now, so the next run's delete never matched).
  The documents importer shares ObsidianImporter.import_scan and inherits this.
- _clear_superseded_denial() now supersedes a billing denial on persisted-state
  content change, not wall-clock comparison. Equal-timestamp entitlements made
  the strict '>' stick forever after a valid reconnect, and a naive '>=' would
  let a pre-denial record saved in the same coarse clock tick resurrect grants.
  cloud_session.saved_session_digest() fingerprints the session bytes so write
  order is observed without exposing credentials.
- tests: deflake ULID same-millisecond ordering (repair cursor sweep count) and
  the archived_at == valid_from boundary (half-open temporal exclusion).

Verified: full offline suite green, ruff/pyright clean, eval gates unchanged.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 200c858e88

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread engraphis/routes/v2_api.py Outdated
Comment on lines +3112 to +3113
and current is not None
and current != _denied_state_digests.get(known_source)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep the denial guard when the baseline digest is unknown

When the state file is unreadable while a 401/402/403 is recorded, the captured digest is None; if denial persistence also fails and the mount later recovers, current becomes a string and therefore differs from that None baseline even though the file still contains the pre-denial active entitlement. The next license read consequently clears the process guard and restores paid grants without a reconnect. Require both the captured and current digests to be known before treating a difference as evidence of a superseding rewrite.

Useful? React with 👍 / 👎.

Comment thread engraphis/core/store.py Outdated
Comment on lines +3845 to +3849
if source_keys:
placeholders = ",".join("?" for _ in source_keys)
return int(self.conn.execute(
f"UPDATE source_imports SET state='missing', missing_at=? WHERE vault_id=? "
f"AND source_key IN ({placeholders})",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Process every manifest row before using exact missing keys

For a vault whose historical manifest has grown beyond 10,000 rows through repeated deletions and additions, import_scan() obtains only the default 10,000 rows from list_source_import_items(). If that page contains any missing candidates, this branch returns after updating only their keys, whereas the previous timestamp-based update covered all unseen rows; a run reported as complete can therefore leave omitted historical rows live until a later complete import happens to fall through to the empty-key path. Fetch the full bounded manifest or page/batch missing detection before taking this exact-key return path.

AGENTS.md reference: AGENTS.md:L215-L219

Useful? React with 👍 / 👎.

P1 (v2_api): _clear_superseded_denial now requires both the captured baseline
digest and the current digest to be known before treating a difference as a
superseding rewrite. An unreadable state file at denial time left None as the
baseline; a later recovered read then differed from it and cleared the guard
even though the file still held the pre-denial active entitlement. Unknown
baselines now stick (fail-closed) until the next denial cycle or restart.

P2 (source imports): list_source_import_items gains offset paging and
ObsidianImporter._all_source_items() pages the full manifest (bounded at 200k
rows) for both import planning and link reconciliation. A manifest grown past
the 10k single-page cap left historical rows beyond the page invisible, so the
explicit source_keys missing-marking path skipped them while the run reported
itself complete.

Verified: unknown-baseline fail-closed probe, reconnect-supersedes probe,
10,050-row paging probe (complete, duplicate-free), full affected test files
(obsidian importer/schema, document importer, hosted plan) exit 0, ruff+pyright
clean.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e374bc1b79

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread engraphis/core/store.py Outdated
Comment on lines +3848 to +3851
return int(self.conn.execute(
f"UPDATE source_imports SET state='missing', missing_at=? WHERE vault_id=? "
f"AND source_key IN ({placeholders})",
(now_ts(), vault_id, *source_keys),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve rows refreshed by a concurrent import

When two dashboard imports target the same vault, an older run can plan a key as missing, a newer run can then upsert that row as active, and this unconditional key-based update will finally overwrite the newer state back to missing. The service starts independent worker threads without vault-level serialization, and this branch no longer checks last_seen_at, last_seen_job_id, or the current state, so it should condition the update on the row still matching the planned generation.

AGENTS.md reference: AGENTS.md:L215-L216

Useful? React with 👍 / 👎.

Comment thread engraphis/core/store.py Outdated
Comment on lines +3847 to +3851
placeholders = ",".join("?" for _ in source_keys)
return int(self.conn.execute(
f"UPDATE source_imports SET state='missing', missing_at=? WHERE vault_id=? "
f"AND source_key IN ({placeholders})",
(now_ts(), vault_id, *source_keys),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Batch the exact-key missing update

On SQLite builds whose host-parameter limit is below len(source_keys) + 2, a complete scan with enough deleted historical rows constructs one oversized IN statement and raises OperationalError; import_scan() then reports the job as failed and leaves all missing rows unfinalized. The new pager admits up to 200,000 manifest rows, which exceeds common 32,766-variable SQLite builds, so these keys need to be updated in bounded batches.

Useful? React with 👍 / 👎.

"""
items: list[dict] = []
page_size = 10_000
for _ in range(20): # bounded: at most 200k manifest rows per import run

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Report or process manifests beyond the paging cap

For a vault with more than 200,000 historical rows, this loop exits after the twentieth full page without indicating truncation, so later rows remain invisible to missing detection and link reconciliation while the import can still report completed. Fresh evidence beyond the earlier 10,000-row finding is the newly introduced fixed 20-iteration cap; either continue until a short page or explicitly fail/mark the run partial when the bound is reached.

AGENTS.md reference: AGENTS.md:L215-L219

Useful? React with 👍 / 👎.

…ging

Three follow-up findings on the source-import missing path:

- Concurrent imports: the exact-key update is now per-key and conditioned on
  the row still matching its planned generation (last_seen_at, last_seen_job_id,
  live state). A newer run that re-upserts a row after an older run planned it
  missing keeps its fresh state instead of being clobbered back to missing.
- SQLite host-parameter limits: the oversized IN clause is gone entirely —
  per-key updates in one transaction scale to any manifest (200k keys marked
  in ~6s locally).
- Paging cap: _all_source_items() now reports whether the full manifest was
  read. Beyond the 200k-row memory bound the run is marked partial, missing
  finalization is skipped, and link reconciliation refuses to retire edges on
  the incomplete view.

Probes: stale-plan-vs-refreshed-row preserved; 200,050-row manifest flags
truncation and marks all 200k planned keys; affected suites (obsidian
importer/schema, document importer, hosted plan, consolidate) exit 0;
ruff+pyright clean.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fa15f95ced

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread engraphis/routes/v2_api.py Outdated
Comment on lines +3113 to +3119
current = _persisted_state_digest(known_source)
baseline = _denied_state_digests.get(known_source)
if (
_AUTHORITATIVE_DENIAL_PENDING.is_set()
and checked_at > _authoritative_denial_at
and baseline is not None
and current is not None
and current != baseline

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Verify the rewrite against the entitlement snapshot

When a license read loads the pre-denial active session while _record_authoritative_denial() is between _mark_authoritative_denial() and its persistence write, that denial write itself changes current from the captured baseline. This branch consequently clears the guard and returns the stale active known value as though a reconnect superseded the denial. Bind the digest to the bytes used to parse known, or re-read the entitlement after detecting a change, so the denial persistence write cannot be mistaken for a newer active answer.

Useful? React with 👍 / 👎.

Comment on lines +970 to +972
page = self.store.list_source_import_items(
vault_id=vault_id, states=states, limit=page_size, offset=len(items),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Read the manifest from a stable snapshot

When two dashboard imports target the same vault, the independent worker started in engraphis/service.py:3173 can rename or insert rows between these page queries. Because OFFSET is applied to the mutable ORDER BY relative_path result, a row moving across the boundary shifts an unread row into an earlier page; the next offset skips it, _all_source_items() can still return manifest_complete=True, and the omitted historical row is never planned or marked missing. Serialize same-vault planning or hold a consistent database snapshot across the whole manifest read.

AGENTS.md reference: AGENTS.md:L215-L216

Useful? React with 👍 / 👎.

Comment thread engraphis/obsidian_import.py Outdated
Comment on lines +969 to +976
for _ in range(20): # bounded: at most 200k manifest rows per import run
page = self.store.list_source_import_items(
vault_id=vault_id, states=states, limit=page_size, offset=len(items),
)
items.extend(page)
if len(page) < page_size:
return items, True
return items, False

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Probe past the cap before declaring the manifest truncated

For a vault containing exactly 200,000 manifest rows, all twenty pages are full, so the loop falls through and reports manifest_complete=False even though no row was truncated. Every complete import at that exact size is therefore forced to partial, and missing-item finalization plus link reconciliation are permanently skipped. Fetch one additional row/page, or otherwise distinguish exactly-at-cap from beyond-cap before returning False.

AGENTS.md reference: AGENTS.md:L215-L216

Useful? React with 👍 / 👎.

…paging

- Denial guard race: the supersession digest now travels with the parse.
  _session_entitlement_snapshot() and _read_entitlement_cache_snapshot() return
  the entitlement plus a sha256 of the exact bytes it was parsed from, and
  _clear_superseded_denial() compares that observed digest against the denial
  baseline instead of re-reading a file that may have changed since. The denial
  persistence write landing between a license read's parse and its check can no
  longer pose as a superseding reconnect. cloud_session gains
  saved_entitlement_snapshot(); _session_entitlement()/_read_entitlement_cache()
  remain as thin wrappers for their other callers.
- Manifest paging: OFFSET is replaced by a (relative_path, id) keyset cursor.
  OFFSET on a live ORDER BY lets a concurrent rename shift an unread row across
  the page boundary so it is silently skipped while the pager reports a complete
  read; the keyset cursor returns every row at or after the cursor exactly once,
  and a row renamed below the read range degrades into content-hash rename
  detection.
- Cap boundary: _all_source_items() probes one row past the 200k-row memory cap,
  so a manifest of exactly 200,000 rows reads as complete instead of forcing
  every such import to partial.

Probes: mid-read denial write leaves the guard set while a post-write reconnect
parse clears it; exactly-at-cap manifest reports complete; beyond-cap reports
partial with a duplicate-free plan set; 200k-row keyset read in ~2s. Affected
suites (obsidian importer/schema, document importer, hosted plan, consolidate)
exit 0; ruff+pyright clean.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: afdcd3cab4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread engraphis/obsidian_import.py Outdated
Comment on lines +370 to +373
self.store.mark_source_import_items_missing(
vault_id=vault_id, seen_before=run_started,
preserve_paths=self._rejected_paths(scan),
missing_items=missing,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Record only rows finalized by the guarded update

When a concurrent same-vault import refreshes a planned-missing row, the generation predicates correctly leave that row active and the store method returns a smaller update count, but this call discards the result and the following loop still records every planned row as missing and includes it in the completed report. The job history and receipt can therefore claim that a live, newer source was removed; propagate the successfully updated identities (or re-read them) and finalize only those rows.

AGENTS.md reference: AGENTS.md:L215-L219

Useful? React with 👍 / 👎.

…marked

mark_source_import_items_missing() returns the source_keys it actually marked
instead of a count: the guarded per-key path collects keys whose generation
predicates matched, and the heuristic path selects its rows before updating
(chunked IN under host-parameter limits). import_scan records job history and
the completed report from that reality — rows the generation guard left live
because a concurrent import refreshed them are recorded as skipped, never as
missing, so the job receipt can no longer claim a live, newer source was
removed.

Probe: stale plan vs refreshed row -> only the stale key returned and marked;
refreshed row stays imported and out of the missing report; heuristic path
returns keys. Affected suites exit 0; ruff+pyright clean.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ff4aba6ef9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread engraphis/obsidian_import.py Outdated
Comment on lines +385 to +386
for item in missing:
if item in finalized:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Check finalized keys in constant time

When a complete import finalizes many historical deletions, finalized can contain nearly all of the up-to-200,000 manifest rows, and this list-membership test scans dictionaries from the beginning for every missing item. That makes post-update job finalization quadratic—roughly 20 billion comparisons at the configured cap—so a large import can appear hung after the database update succeeds; use the already-built marked_keys set for this check instead.

Useful? React with 👍 / 👎.

…zation

The guard-skipped loop tested membership against the finalized list, scanning
up to 200k dicts per missing item — quadratic job finalization that can appear
hung on a large complete import. Test the item's source_key against the
already-built marked_keys set instead.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant