Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
33 changes: 33 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ schema、Python type、threat fixture 或新的架构决策。
| `File source cleanup intent` | Persistent immutable pending-cleanup lineage | One Organization, disabled ContextSource, and its exact active SourceVersion at offboarding | None; it neither authorizes reads nor performs cleanup |
| `acquisition checkpoint` | Persistent monotonic acquisition progress | One Organization and ContextSource | None |
| `publish watermark` | Persistent monotonic visibility progress | One Organization and ContextSource | None |
| `File change baseline` | Persistent bounded projection of one complete accepted File scan | One Organization, ContextSource, and exact SourceVersion | Observation lineage only; never tombstone or Runtime authority |
| `File delete observation` | Persistent content-free vanished-path evidence | One Organization, ContextSource, SourceVersion, scan, and prior baseline upsert | None; deletion execution remains separate |

Reviewer classification: `ContextRevision` and `ContextFragment` are persistent;
`Evidence` is request-scoped. None of the three is an authorization authority.
Expand Down Expand Up @@ -482,6 +484,37 @@ accepted. 中文:acquisition checkpoint 只表示变化已 durable 接收,
- **Do not confuse with:** publish watermark, active ContextRevision, worker
acknowledgement, or Policy Epoch. `acquisition cursor` maps here.

### `File change baseline`

The bounded content-free projection of the latest complete accepted v4 File
scan for one exact active SourceVersion. 中文:File change baseline 只记录完整 scan
的 path、change kind、digest、length 与 checkpoint lineage,用于下一次稳定浅层
diff,不保存正文。

- **Owner/scope:** one Organization, ContextSource, and immutable
SourceVersion; an incomplete or older-version scan cannot replace it.
- **Lifecycle:** advances only when a v4 scan's terminal page commits; the
current scan head may advance independently while incomplete.
- **Invariant:** baseline input is Control-projected, bounded, canonical, and
content-free. It neither authorizes Runtime delivery nor executes deletion.
- **Do not confuse with:** acquisition checkpoint, publish watermark,
ContextRevision snapshot, caller manifest, or source ACL evidence.

### `File delete observation`

The content-free statement that a path present as an exact prior baseline
upsert is absent from the current stable shallow File snapshot. 中文:File delete
observation 是消失路径的 acquisition evidence,不是 Resource tombstone 指令。

- **Owner/scope:** one Organization, ContextSource, exact SourceVersion, scan,
and exact complete comparison baseline.
- **Lifecycle:** immutable inside an accepted change page; exact replay returns
the same observation.
- **Invariant:** retains only prior path, raw digest, and byte length. Acceptance
changes no tombstone, Policy Epoch, cleanup intent, or Runtime visibility.
- **Do not confuse with:** tombstone, physical cleanup, ActionPlane delete,
failed file read, or scheduler authority.

### `publish watermark`

The durable, monotonic signal recording which accepted changes completed
Expand Down
116 changes: 108 additions & 8 deletions adapters/file_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@
from engine._opaque import decode_base64url, encode_base64url
from engine.control import (
FILE_CHANGE_CAPABILITY_MANIFEST,
FILE_DELETE_OBSERVATION_CAPABILITY_MANIFEST,
CapabilityStatus,
ChangeCursor,
ChangeLimit,
ChangePage,
FileChangeBaselineRef,
FileChangeKind,
FileChangeProviderOutcome,
FileChangeProviderProofs,
Expand Down Expand Up @@ -302,6 +304,14 @@ class _ObservedFile:
content_length: int


@dataclass(frozen=True, slots=True)
class _ObservedChange:
kind: FileChangeKind
path: FileImportPath
content_sha256: str
content_length: int


class FileChangeProvider:
"""Deterministic shallow File `readChanges` Provider implementation."""

Expand Down Expand Up @@ -344,7 +354,11 @@ def read_changes(
return ProviderGenericDenied()
capabilities = source.source_version.capabilities
if (
capabilities != FILE_CHANGE_CAPABILITY_MANIFEST
capabilities
not in {
FILE_CHANGE_CAPABILITY_MANIFEST,
FILE_DELETE_OBSERVATION_CAPABILITY_MANIFEST,
}
or capabilities.read_changes is not CapabilityStatus.AVAILABLE
):
return ProviderUnsupported("readChanges")
Expand All @@ -363,7 +377,8 @@ def read_changes(
return ProviderGenericDenied()
except RuntimeError:
return ProviderRetryableUnavailable(timedelta(seconds=1))
scan_ref = self._scan_ref(source, observed)
changes, baseline_ref = self._changes(source, observed)
scan_ref = self._scan_ref(source, changes, baseline_ref)
requested_limit = limit
if (
type(cursor) is InitialScan
Expand Down Expand Up @@ -399,17 +414,17 @@ def read_changes(
scan_ref=scan_ref,
scan_epoch=scan_epoch,
limit=requested_limit,
observed_count=len(observed),
observed_count=len(changes),
):
return ProviderInvalidCheckpoint()
offset = cast(int, claims["offset"])
predecessor_page_ref = accepted.page_ref
predecessor_checkpoint_ref = accepted.checkpoint_ref
predecessor_sequence = accepted.sequence
superseded_scan_epoch = None
selected = observed[offset : offset + requested_limit.value]
selected = changes[offset : offset + requested_limit.value]
next_offset = offset + len(selected)
complete = next_offset == len(observed)
complete = next_offset == len(changes)
next_cursor = (
None
if complete
Expand All @@ -432,13 +447,14 @@ def read_changes(
predecessor_checkpoint_ref=predecessor_checkpoint_ref,
predecessor_sequence=predecessor_sequence,
superseded_scan_epoch=superseded_scan_epoch,
baseline_ref=baseline_ref,
changes=tuple(
SourceChange(
organization_id=source.organization_id,
source_ref=source.source_version.source_ref.value,
source_version_ref=source.source_version.version_ref,
scan_ref=scan_ref,
kind=FileChangeKind.UPSERT,
kind=item.kind,
path=item.path,
content_sha256=item.content_sha256,
content_length=item.content_length,
Expand All @@ -448,29 +464,113 @@ def read_changes(
next_cursor=next_cursor,
complete=complete,
provider_proof="A" * 86,
capability_version=capabilities.declaration_version,
)
return ProviderOk(
replace(unsigned, provider_proof=self._proofs._seal_page(unsigned))
)

@staticmethod
def _scan_ref(
def _changes(
source: FileChangeSource,
observed: tuple[_ObservedFile, ...],
) -> tuple[tuple[_ObservedChange, ...], FileChangeBaselineRef | None]:
baseline = source.complete_baseline
current = {
item.path: (item.content_sha256, item.content_length)
for item in observed
}
if baseline is not None:
active = {
entry.path: (entry.content_sha256, entry.content_length)
for entry in baseline.entries
if entry.kind is FileChangeKind.UPSERT
}
deleted = {
entry.path
for entry in baseline.entries
if entry.kind is FileChangeKind.DELETE
}
if current == active and deleted.isdisjoint(current):
# Reuse the prior scan's comparison input so a no-op replay
# retains the exact scan_ref instead of advancing its lineage.
return (
Comment thread
stone16 marked this conversation as resolved.
tuple(
_ObservedChange(
kind=entry.kind,
path=entry.path,
content_sha256=entry.content_sha256,
content_length=entry.content_length,
)
for entry in baseline.entries
),
baseline.reference.comparison_baseline_ref,
)
changes = [
_ObservedChange(
kind=FileChangeKind.UPSERT,
path=item.path,
content_sha256=item.content_sha256,
content_length=item.content_length,
)
for item in observed
]
if baseline is not None:
changes.extend(
Comment thread
stone16 marked this conversation as resolved.
_ObservedChange(
kind=FileChangeKind.DELETE,
path=entry.path,
content_sha256=entry.content_sha256,
content_length=entry.content_length,
)
for entry in baseline.entries
if entry.kind is FileChangeKind.UPSERT
and entry.path not in current
)
changes.sort(key=lambda item: item.path.value.encode("utf-8"))
return tuple(changes), None if baseline is None else baseline.reference

@staticmethod
def _scan_ref(
source: FileChangeSource,
observed: tuple[_ObservedChange, ...],
baseline_ref: FileChangeBaselineRef | None,
) -> str:
document = {
document: dict[str, object] = {
"organizationId": str(source.organization_id),
"sourceId": str(source.source_version.source_ref.value),
"sourceVersionId": str(source.source_version.version_ref),
"entries": [
{
"contentLength": item.content_length,
"contentSha256": item.content_sha256,
**(
{"kind": item.kind.value}
if source.source_version.capabilities
is FILE_DELETE_OBSERVATION_CAPABILITY_MANIFEST
else {}
),
"path": item.path.value,
}
for item in observed
],
}
if (
source.source_version.capabilities
is FILE_DELETE_OBSERVATION_CAPABILITY_MANIFEST
):
document["baseline"] = (
None
if baseline_ref is None
else {
"checkpointRef": baseline_ref.checkpoint_ref,
"pageRef": baseline_ref.page_ref,
"scanEpoch": str(baseline_ref.scan_epoch),
"scanRef": baseline_ref.scan_ref,
"sequence": baseline_ref.sequence,
"sourceVersionId": str(baseline_ref.source_version_ref),
}
)
return hashlib.sha256(
_SCAN_DOMAIN + rfc8785.dumps(cast(Any, document))
).hexdigest()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
---
name: adr-0056-detect-file-deletions-without-tombstone-authority
version: "1.0.0"
description: >
Detect vanished shallow File paths from one exact complete baseline while
keeping deletion execution, Runtime visibility, and audience authority closed.
---

# 0056. Detect File deletions without tombstone authority

- Status: accepted
- Date: 2026-07-25
- Refines: ADR-0037, ADR-0043, ADR-0054, ADR-0055

## Context

File v3 pages describe only files present in the current shallow snapshot. An
omitted path is ambiguous: it may be unchanged, never observed, or deleted.
Issue #85 needs durable deletion evidence, but Issue #28 already owns the only
authoritative Resource tombstone transition. Treating a provider omission,
cursor, or checkpoint as tombstone authority would bypass that transition and
its Policy Epoch, cleanup-intent, and Runtime visibility gates.

## Decision

An explicit Control operation advances an active v3 File SourceVersion to the
immutable `file-capabilities-v4` declaration. V4 adds
`deleteObservations=available`; `deletion`, `discover`,
`authorizeAndProject`, recursive discovery, freshness, and consistency remain
unavailable.

`read_file_source_progress` projects a bounded, content-free baseline only from
the latest complete accepted v4 scan for the exact active Organization,
ContextSource, and SourceVersion. It returns the terminal page/checkpoint
identity, its one-level comparison parent, and canonical change envelopes. The
progress row and complete baseline are read through two explicitly `STABLE`
database functions in one PostgreSQL statement snapshot. The migration owns
both volatility declarations and restores the predecessor progress function's
`VOLATILE` declaration on downgrade. The current scan head remains separate,
so an incomplete or concurrently committed scan never tears or replaces the
baseline paired with that progress observation.

`FileChangeProvider.read_changes` takes one stable shallow Markdown snapshot,
builds the whole canonical diff before pagination, and binds the exact baseline
reference into the scan digest and every page proof. Current paths are
`upsert`; a prior baseline `upsert` absent from the snapshot is `delete` with
only the prior path, raw digest, and length. With no complete baseline, no
delete can be emitted. Exact unchanged state replays the original scan and its
original comparison baseline.

One v4 SECURITY DEFINER acceptance function requires the referenced baseline to
be the latest complete page for the same active SourceVersion. Every delete
must exactly match a prior baseline `upsert(path, digest, length)`. It rejects
missing, incomplete, stale, cross-Organization, forged, out-of-order, or
over-limit lineage before a page/checkpoint commit. Accepted v4 pages bind their
baseline in a tenant-owned FORCE-RLS table; page, change, binding, and
checkpoint commit atomically. Its insert guard consumes the trusted transaction
tenant GUC and requires an exact Organization match; row data can neither mint
nor rewrite tenant context. Exact replay is identical.

A page containing any delete is ineligible for
`schedule_file_change_page`. Page acceptance creates no acquisition, import
job, Revision, candidate, ContextRun, tombstone, Policy Epoch update, cleanup
intent, or publish watermark. The previously published Resource remains
visible through the sealed Runtime and generated SDK until a later execution
owner revalidates current deletion lineage and calls the existing Issue #28
tombstone authority.

## Consequences

- Supply can durably distinguish a vanished path from an absent observation.
- Baseline size is server-bounded and cannot be caller-authored or recursively
expanded.
- Delete observation and deletion execution remain separate capabilities.
- Existing v3 page acceptance and all-upsert scheduling stay unchanged.
- Downgrade restores each active v4 source to its immutable v3 predecessor only
while no accepted v4 page, acquisition, cleanup, or retained ActionTicket
lineage exists; otherwise it refuses and requires a forward fix.
- Autonomous polling, retry/reclaim, dead-letter handling, recursive scanning,
full resync, restore/recreate, and rename correlation remain inactive.

## Revisit trigger

Revisit before any accepted delete can invoke tombstone execution. That owner
must revalidate the latest complete baseline and current source snapshot before
calling the Issue #28 authority; it may not infer audience, reuse an upsert
schedule, or make baseline metadata a Runtime authorization input.
5 changes: 4 additions & 1 deletion docs/security/context-engine-threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ An inactive capability is `NOT_ACTIVE`; it is never reported as `PASS`.
| `TM-05` | Live or Mirrored ACL failure silently downgrades to Weak or public access. | `Live | Mirrored | Weak` is a closed SourcePolicy choice; missing, stale, incomplete, changed, or failed strong evidence denies. |
| `TM-06` | Revoked access survives through an old epoch, cache, continuation, citation, or source snapshot. | New controlled operations validate current policy/source evidence; stale capabilities and cached decisions yield no Evidence. |
| `TM-07` | A worker impersonates a user or reuses authority across jobs, sources, generations, or Organizations. | Least-privilege ServiceActor plus signed exact-job WorkerLease checked against current durable state; mutation/replay changes no durable state. |
| `TM-08` | Crash or retry exposes mixed Revisions, skipped changes, duplicate effects, or an unapproved release. | Transactional outbox, dual watermarks, immutable versions, atomic active pointers, idempotency, and fault-point tests. |
| `TM-08` | Crash or retry exposes mixed Revisions, skipped changes, duplicate effects, or an unapproved release. | Transactional outbox, dual watermarks, immutable versions, atomic active pointers, idempotency, and fault-point tests. File delete observations additionally require the exact latest complete same-SourceVersion baseline and have zero tombstone or visibility effect. |
| `TM-09` | Index or cache filtering is treated as authorization. | Index/cache outputs remain content-free candidates; deliberate cross-Organization hits are removed by Kernel before content work. |
| `TM-10` | Authenticated BotDelivery forges trusted delivery facts or replays another resolve's evidence. | Opaque `DeliveryEvidenceRef` is service/request/org/asker/destination/purpose/audience/expiry-bound and redeemed once at ingress. |
| `TM-11` | Group content is authorized only for the asker, an incomplete member set, or a stale audience. | Kernel computes the complete audience intersection; public and asker-private paths use separate resolves; unknown or stale audience means public bytes equal zero. |
Expand All @@ -129,6 +129,9 @@ An inactive capability is `NOT_ACTIVE`; it is never reported as `PASS`.
disabled for sensitive content.
- Host file ownership or mode bits do not implicitly authorize FileProvider
content. File access uses an explicit, active, versioned `FileSourceAccess`.
- A File delete observation is acquisition evidence only. It cannot tombstone a
Resource, advance Policy Epoch, create cleanup intent, or alter Runtime
visibility; deletion execution must use its separately revalidated authority.
- Cross-Organization learning is not a V1 capability. No schema or executable
path is created for it without a new opt-in/privacy ADR and raw reference
oracle.
Expand Down
10 changes: 10 additions & 0 deletions engine/control/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
from engine.control.contracts import (
FILE_CAPABILITY_MANIFEST,
FILE_CHANGE_CAPABILITY_MANIFEST,
FILE_DELETE_OBSERVATION_CAPABILITY_MANIFEST,
FILE_IMPORT_CAPABILITY_MANIFEST,
ActivateFileChangeFeed,
ActivateFileDeleteObservations,
CapabilityStatus,
FileCapabilityManifest,
FileRootRef,
Expand All @@ -35,6 +37,9 @@
ChangeCursor,
ChangeLimit,
ChangePage,
FileChangeBaseline,
FileChangeBaselineEntry,
FileChangeBaselineRef,
FileChangeControlProofs,
FileChangeKind,
FileChangeProviderOutcome,
Expand Down Expand Up @@ -83,8 +88,10 @@
"MAX_FILE_CHANGE_PAGE_SIZE",
"AcceptedChangePage",
"ActivateFileChangeFeed",
"ActivateFileDeleteObservations",
"FILE_CAPABILITY_MANIFEST",
"FILE_CHANGE_CAPABILITY_MANIFEST",
"FILE_DELETE_OBSERVATION_CAPABILITY_MANIFEST",
"FILE_IMPORT_CAPABILITY_MANIFEST",
"CapabilityStatus",
"ChangeCursor",
Expand All @@ -102,6 +109,9 @@
"FileChangeKind",
"FileChangeProviderOutcome",
"FileChangeProviderProofs",
"FileChangeBaseline",
"FileChangeBaselineEntry",
"FileChangeBaselineRef",
"FileChangeScanHead",
"FileChangeSource",
"FileImportAudience",
Expand Down
Loading
Loading