diff --git a/application/single_app/config.py b/application/single_app/config.py index 5caf0c223..c13b8dca5 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.231" +VERSION = "0.261.232" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/content_screening/access.py b/application/single_app/content_screening/access.py index 744835af8..345957bad 100644 --- a/application/single_app/content_screening/access.py +++ b/application/single_app/content_screening/access.py @@ -14,10 +14,13 @@ An injected ``units_reader(reference, subject)`` supplies canonical units. ``assert_evidence_available(evidence, user_id=None, *, metadata_reader=None, -cached=False, strict_errors=False)`` checks known provenance on every reuse. Set ``cached=True`` for -historical results: an old unversioned context cannot borrow a new clearance. +cached=False, strict_errors=False)`` checks known provenance when evidence is read +as an input. Set ``cached=True`` for evidence carried within one operation: an old +unversioned context cannot borrow a new clearance. Generated results and stored +conversation history are never passed here; they take their access from their +container (the conversation, workflow or orchestration run). -Server-owned retained-result operations opt into typed authority failures with +Server-owned input reads opt into typed authority failures with ``strict_errors=True`` or ``strict_source_authority()``. A headless owner that catches errors keeps the latter scope around the entire source/model decision. @@ -637,7 +640,7 @@ def assert_document_chunks_available( def assert_evidence_available(evidence, user_id=None, *, metadata_reader=None, cached=False, strict_errors=False): - """Revalidate known workspace provenance before using saved/model evidence.""" + """Revalidate known workspace provenance before evidence is used as an input.""" if type(strict_errors) is not bool: raise TypeError("The source authority error policy must be server-owned.") if strict_errors: @@ -842,7 +845,14 @@ def refresh_workspace_attachment(message, user_id=None): def public_history_messages(messages, user_id=None): - """Withhold unavailable source material without deleting ordinary chat text.""" + """Return conversation history without rechecking where generated content came from. + + Assistant and tool messages, and the citations stored with them, take their access + from the conversation the caller already authorized, so their sources are never + checked again. A file or image message backed by a workspace document is an input + read: only that message is replaced when the reader can no longer open the document + or it is held. + """ # This read boundary already has conversation authorization. Keep the chat # adapter out of the document contracts' import/bootstrap dependency chain. from functions_chat_content_checks import strip_private_chat_checks @@ -862,7 +872,6 @@ def public_history_messages(messages, user_id=None): message = refresh_checked_message(message) refreshed = refresh_workspace_attachment(message, user_id) - assert_evidence_available(refreshed, user_id, cached=True) except (ScreeningError, LookupError, PermissionError): if request_context: flask.g.content_screening_error = previous_error @@ -882,8 +891,10 @@ def public_history_messages(messages, user_id=None): }) safe_messages.append(safe) else: - # This dispatcher handles per-file denials itself. Operational failures - # must escape rather than become an unrelated document-screening hold. + # Generated files keep their container checks (conversation, workflow or + # orchestration run) and lose only private storage bindings here; their + # source documents are not rechecked. Operational failures must escape + # rather than become an unrelated document-screening hold. from functions_generated_artifact_sources import sanitize_generated_artifact_history safe_messages.append(strip_private_chat_checks( diff --git a/application/single_app/functions_generated_artifact_sources.py b/application/single_app/functions_generated_artifact_sources.py index 0eb59a55f..e1625153d 100644 --- a/application/single_app/functions_generated_artifact_sources.py +++ b/application/single_app/functions_generated_artifact_sources.py @@ -131,7 +131,11 @@ def authorize_generated_artifact_source(user_id, artifact, *, for_publication=Fa def sanitize_generated_artifact_history(message, user_id): - """Reauthorize saved-output cards and strip private bindings from history.""" + """Apply each generated file's container access and strip private bindings from history. + + A saved-output card or file message stays visible while its conversation, workflow or + orchestration run allows it. The documents it was generated from are never rechecked. + """ metadata = message.get("metadata") or {} orchestration_history = ( is_orchestration_artifact_source(metadata.get("generated_artifact_source")) @@ -154,7 +158,7 @@ def sanitize_generated_artifact_history(message, user_id): return message def authorize(conversation_id, message_id): - # Reuse the complete conversation/approval/source/screening boundary for saved cards. + # Reuse the conversation, approval and publication boundary for saved cards. from route_enhanced_citations import _get_authorized_chat_artifact_message request_context = has_request_context() diff --git a/application/single_app/functions_orchestration_executor.py b/application/single_app/functions_orchestration_executor.py index 860d9e6c3..9209833cc 100644 --- a/application/single_app/functions_orchestration_executor.py +++ b/application/single_app/functions_orchestration_executor.py @@ -879,12 +879,18 @@ def _run_dependency_step( manifest = _dependency_source_manifest(context, document_ids, settings, cancel_probe) if named_sources: by_id = {source['document_id']: source for source in manifest} + named_by = readers['sources'].reference.producer + # Sources named earlier in this attempt must still be the version that step read. + # A list reused from an earlier run or attempt only selects documents: they pass + # the input check above and are read at their current version. + same_attempt = (named_by.run_id, named_by.attempt_index) == (producer.run_id, producer.attempt_index) + fields = ('scope', 'scope_id', 'source_version', 'source_revision') if same_attempt else ('scope', 'scope_id') for source in named_sources: current = by_id.get(source['document_id'], {}) - if any( - current.get(field) != source.get(field) - for field in ('scope', 'scope_id', 'source_version', 'source_revision') - ) or (source.get('content_sha256') is not None and current.get('content_sha256') != source['content_sha256']): + if any(current.get(field) != source.get(field) for field in fields) or ( + same_attempt and source.get('content_sha256') is not None + and current.get('content_sha256') != source['content_sha256'] + ): raise ResultUnavailableError('result_source_snapshot_changed') scoped = _dependency_adapter_context(context, manifest, step, input_fingerprint) binding_scope = getattr(context, 'step_model_scope', None) diff --git a/application/single_app/functions_orchestration_native_results.py b/application/single_app/functions_orchestration_native_results.py index c7df73208..38ddfb340 100644 --- a/application/single_app/functions_orchestration_native_results.py +++ b/application/single_app/functions_orchestration_native_results.py @@ -498,7 +498,8 @@ def _sources(step, context, service, *, require_current, require_replay=False): ): raise NativeOrchestrationBridgeError("native_selection_unsupported") snapshots = analysis_source_snapshot(manifest) - service.access.authorize_sources(snapshots, require_snapshot=require_current) + # The native engine reads the uploaded tabular file itself, so this is an input read. + service.access.authorize_input_sources(snapshots, require_snapshot=require_current) return deepcopy(manifest), snapshots diff --git a/application/single_app/functions_orchestration_output_store.py b/application/single_app/functions_orchestration_output_store.py index 7b25977a2..20b2c8dc1 100644 --- a/application/single_app/functions_orchestration_output_store.py +++ b/application/single_app/functions_orchestration_output_store.py @@ -37,7 +37,7 @@ "waiting", "rendering", "retry_scheduled", "completed", "failed", "cancelled", }) OUTPUT_UNAVAILABLE_MESSAGES = { - "output_access_denied": "This file is unavailable because current source access could not be confirmed.", + "output_access_denied": "This file is unavailable because access to its conversation could not be confirmed.", "output_screening_hold": "This file is unavailable while its source is under review.", "output_source_unavailable": "This file is unavailable because its retained source is missing or no longer readable.", "output_source_changed": "This file is unavailable because its source no longer matches the retained version.", diff --git a/application/single_app/functions_orchestration_recovery.py b/application/single_app/functions_orchestration_recovery.py index 1c5b33185..f3ec49978 100644 --- a/application/single_app/functions_orchestration_recovery.py +++ b/application/single_app/functions_orchestration_recovery.py @@ -997,7 +997,12 @@ def request_cancellation(run_id, user_id, conversation_id, authorize, *, analysi def _validate_payload_sources(payload, context, settings, user_id): - """Recheck every retained result a checkpoint references, and its current sources.""" + """Recheck every retained result a checkpoint references. + + Each result's producer, lineage integrity and external sources are checked again. + The documents those results were produced from are not reread; a resumed step that + reads documents itself checks them as inputs when it runs. + """ state = payload.get('state') or {} if getattr(context, 'plan_contract_version', None) != 2 or state.get('plan_contract_version') != 2: raise CheckpointError('checkpoint_invalid') diff --git a/application/single_app/functions_orchestration_rendering.py b/application/single_app/functions_orchestration_rendering.py index 9bef81cf7..d06bf74c1 100644 --- a/application/single_app/functions_orchestration_rendering.py +++ b/application/single_app/functions_orchestration_rendering.py @@ -74,7 +74,7 @@ MAX_OUTPUT_BYTES = 500 * 1024 * 1024 -# A render re-proves its claim, run state, capability admission and source access on this +# A render re-proves its claim, run state, capability admission and result lineage on this # cadence rather than on every block it writes. Publication boundaries still check in full. RENDER_FULL_CHECK_INTERVAL_SECONDS = 5.0 _REQUEST_FIELDS = frozenset({"output_format", "profile", "columns", "title", "sheet_name"}) @@ -738,7 +738,7 @@ def _public_records(self, run_id): yield public_output(record), record def list_public_outputs(self, run_id): - """Current per-file visibility; a source denial cannot hide its siblings.""" + """Current per-file state; one unavailable file cannot hide its siblings.""" return [projection for projection, _ in self._public_records(run_id)] def claim_due(self, output_id, *, worker_id=None): diff --git a/application/single_app/functions_orchestration_results.py b/application/single_app/functions_orchestration_results.py index bc4d6d5c3..ecffb062b 100644 --- a/application/single_app/functions_orchestration_results.py +++ b/application/single_app/functions_orchestration_results.py @@ -42,7 +42,7 @@ validate_record, validate_result_role, ) -from functions_orchestration_source_access import authorize_orchestration_sources +from functions_orchestration_source_access import verify_orchestration_input_sources from functions_workflow_collections import ( CollectionWriteBudget, RecordTreeWriter, @@ -90,14 +90,16 @@ def __post_init__(self): class OrchestrationResultAccess: - """Server-owned owner/run readers and authorized, screening-aware source I/O. + """Server-owned owner/run readers, plus screening-aware source I/O for input reads. ``read_conversation(conversation_id)`` and ``read_run(run_id)`` return current - server records, not request payloads. ``source_resolver(ids, **scope)`` has the - existing resolve_authorized_source_manifest protocol. ``source_metadata_reader`` - has content_screening.access's metadata_reader protocol and must enforce the - current actor's document/workspace access. Source-free results need neither - source callback. Original-owner, same-conversation reuse is the v1 scope. + server records, not request payloads. A retained result takes its access from them + alone: the documents it was produced from are never rechecked when it is read. + ``source_resolver(ids, **scope)`` has the existing resolve_authorized_source_manifest + protocol. ``source_metadata_reader`` has content_screening.access's metadata_reader + protocol and must enforce the current actor's document/workspace access. Both are + used only by ``authorize_input_sources``, when a step reads uploaded documents + itself. Original-owner, same-conversation reuse is the v1 scope. """ def __init__( @@ -165,7 +167,11 @@ def authorize_producer(self, producer, *, for_write=False): ): raise ResultUnavailableError("result_attempt_stopped") - def authorize_sources(self, sources, *, require_snapshot): + def authorize_input_sources(self, sources, *, require_snapshot): + """Input reads only: check uploaded documents a step is about to read itself. + + Retained results never call this; their access comes from ``authorize_producer``. + """ if not sources: return {"source_count": 0, "source_snapshot_changed": False} if self.source_resolver is None or self.source_metadata_reader is None: @@ -188,7 +194,7 @@ def resolve(document_ids, **scope): raise ResultUnavailableError("result_source_snapshot_changed") return fresh - checked = authorize_orchestration_sources( + checked = verify_orchestration_input_sources( self.user_id, sources, require_snapshot=require_snapshot, resolver=resolve, metadata_reader=self.source_metadata_reader, ) @@ -445,17 +451,22 @@ def _load_manifest(self, reference): return manifest def _authorize_lineage(self, producer, lineage, *, for_write=False, force_current=False, active=None, visited=None): + """Check the producer chain and lineage integrity, never the source documents. + + A retained result takes its access from its conversation and run. Its recorded + sources are provenance: they bound which source and evidence items its outputs + may name, but they are not read again. External (agent, action, memory and web) + sources keep their own current checks. + """ self.access.authorize_producer(producer, for_write=for_write) parents = _lineage(lineage) current = force_current or lineage["source_policy"] == "current" - checked = self.access.authorize_sources(lineage["sources"], require_snapshot=current) source_snapshots = { _source_key(source): {canonical_bytes(source)} for source in lineage["sources"] } - external_changed, external_sources = self.access.authorize_external_sources( + changed, external_sources = self.access.authorize_external_sources( producer, lineage.get("external_sources", []), require_snapshot=current, ) - changed = checked["source_snapshot_changed"] or external_changed active = set() if active is None else active visited = {} if visited is None else visited for parent in parents: @@ -653,6 +664,11 @@ def recover_task_result(self, *, producer, input_fingerprint): return task def open_result(self, reference, *, allow_partial=False, require_current_sources=False): + """Open a retained result after its producer and lineage checks. + + ``require_current_sources`` applies the current-version rule to external sources + only. The documents a result was produced from are provenance and are not reread. + """ if type(reference) is not ResultRef or type(require_current_sources) is not bool: raise ResultContractError("result_reference_untrusted") reference.completeness.require_readable(allow_partial=allow_partial) diff --git a/application/single_app/functions_orchestration_source_access.py b/application/single_app/functions_orchestration_source_access.py index 77b86fa44..8d5dccfbb 100644 --- a/application/single_app/functions_orchestration_source_access.py +++ b/application/single_app/functions_orchestration_source_access.py @@ -1,10 +1,13 @@ # functions_orchestration_source_access.py -"""Strict current-authority source services for retained orchestration results. +"""Strict current-authority source services for orchestration input reads. -Version: 0.261.127 +Version: 0.261.232 -Owners bind these runtime callbacks, not a browser-selectable policy. Legacy -screening/search callers outside the explicit scope retain their old behavior. +Owners bind these runtime callbacks, not a browser-selectable policy. They are used +only when a step reads uploaded documents as inputs. Retained orchestration results +take their access from the conversation and run that produced them and never call +back into these services. Legacy screening/search callers outside the explicit scope +retain their old behavior. """ from content_screening.access import ( @@ -48,10 +51,15 @@ def read_orchestration_source_metadata(document_id, user_id, group_id=None, publ ) -def authorize_orchestration_sources( +def verify_orchestration_input_sources( user_id, sources, *, require_snapshot, resolver, metadata_reader, ): - """Check retained lineage with an explicit server-owned strict authority boundary.""" + """Input reads only: confirm a step may still read the uploaded documents it selected. + + The reader must still have access, no document may be held, and with + ``require_snapshot`` each document must still be the version the step started from. + Never call this for a retained result: those take their access from their container. + """ def resolve(document_ids, **scope): try: return resolver(document_ids, **scope) diff --git a/application/single_app/functions_saved_analysis.py b/application/single_app/functions_saved_analysis.py index f63589e90..bf880ac97 100644 --- a/application/single_app/functions_saved_analysis.py +++ b/application/single_app/functions_saved_analysis.py @@ -13,7 +13,7 @@ from azure.core.exceptions import AzureError from azure.cosmos.exceptions import CosmosHttpResponseError, CosmosResourceNotFoundError -from functions_analysis_access import AnalysisResultUnavailable, authorize_analysis_sources +from functions_analysis_access import AnalysisResultUnavailable, analysis_source_snapshot from functions_appinsights import log_event from functions_generated_file_exports import build_saved_analysis_export from functions_workflow_context import WorkflowContextBudgetError, calculate_workflow_context_budget @@ -260,11 +260,30 @@ def _orchestration_load(user_id, conversation_id, run_id, step_id, reference): return load_orchestration_analysis_result(user_id, conversation_id, run_id, step_id, reference) +def _saved_source_provenance(sources): + """Describe a saved result's recorded sources. Provenance only, never an access check. + + A saved result takes its access from its conversation or orchestration run, so its + source documents are not read again. A result without a valid source list is a + lineage failure. + """ + snapshots = analysis_source_snapshot(sources) + if not snapshots: + raise AnalysisResultUnavailable("analysis_source_manifest_missing") + return {"source_count": len(snapshots), "source_snapshot_changed": False} + + def save_orchestration_analysis( result, *, user_id, conversation_id, run_id, step_id, settings=None, authorize_run=None, save_result=None, source_resolver=None, guard_token=None, ): - """Persist a step result before terminal chat presentation is generated.""" + """Persist a step result before terminal chat presentation is generated. + + The run is the container: its producer is checked before and after the write. The + documents the analysis read are recorded as provenance and are not checked again. + ``source_resolver`` is accepted for older callers and is not used. + """ + del source_resolver if settings is None and save_result is None: from functions_settings import get_settings settings = get_settings() @@ -274,9 +293,7 @@ def save_orchestration_analysis( binding = envelope["identity"] authorize = authorize_run or _authorize_orchestration_producer authorize(user_id, binding) - access = authorize_analysis_sources( - user_id, envelope["analysis_access"]["sources"], require_snapshot=True, resolver=source_resolver, - ) + access = _saved_source_provenance(envelope["analysis_access"]["sources"]) save = save_result or _orchestration_save manifest, reference = persist_result_sections( envelope, @@ -287,9 +304,6 @@ def save_orchestration_analysis( max_result_bytes=_quota_bytes(settings or {}), ) authorize(user_id, binding) - authorize_analysis_sources( - user_id, envelope["analysis_access"]["sources"], require_snapshot=True, resolver=source_resolver, - ) return { "version": SAVED_ANALYSIS_VERSION, "result_ref": reference, "result_sha256": reference["sha256"], "binding": dict(binding), "conversation_id": conversation_id, @@ -303,7 +317,13 @@ def save_chat_analysis( result, *, user_id, conversation_id, message_id, settings=None, authorize_conversation=None, save_result=None, source_resolver=None, guard_token=None, ): - """Save final sections once, then advertise only their committed manifest.""" + """Save final sections once, then advertise only their committed manifest. + + The conversation is the container: it is checked before and after the write. The + documents the analysis read are recorded as provenance and are not checked again. + ``source_resolver`` is accepted for older callers and is not used. + """ + del source_resolver if settings is None and save_result is None: from functions_settings import get_settings settings = get_settings() @@ -312,9 +332,7 @@ def save_chat_analysis( envelope = build_chat_analysis_result( result, user_id=user_id, conversation_id=conversation_id, message_id=message_id, ) - access = authorize_analysis_sources( - user_id, envelope["analysis_access"]["sources"], require_snapshot=True, resolver=source_resolver, - ) + access = _saved_source_provenance(envelope["analysis_access"]["sources"]) save = save_result or _chat_save manifest, reference = persist_result_sections( envelope, @@ -325,9 +343,6 @@ def save_chat_analysis( max_result_bytes=_quota_bytes(settings or {}), ) authorize(user_id, conversation_id) - authorize_analysis_sources( - user_id, envelope["analysis_access"]["sources"], require_snapshot=True, resolver=source_resolver, - ) return { "version": SAVED_ANALYSIS_VERSION, "conversation_id": conversation_id, @@ -799,7 +814,12 @@ def load_saved_analysis( workflow_getter=None, workflow_loader=None, source_resolver=None, orchestration_authorizer=None, orchestration_loader=None, ): - """Authorize the displayed message, its original producer, and all sources.""" + """Authorize the displayed message and its original producer, then read the result. + + Access comes from the container: the conversation, workflow or orchestration run. + The source documents are provenance and are not checked again; lineage and integrity + failures still refuse the read. + """ context = saved_analysis_context(context) load_message = message_loader or _load_authorized_message message = load_message(user_id, context["conversation_id"], context["message_id"]) @@ -855,9 +875,7 @@ def load_saved_analysis( access = manifest.get("analysis_access") or {} if not isinstance(access, Mapping) or access.get("version") != ANALYSIS_SOURCE_ACCESS_VERSION: raise AnalysisResultUnavailable("analysis_lineage_invalid") - checked = authorize_analysis_sources( - user_id, access.get("sources"), resolver=source_resolver, - ) + checked = _saved_source_provenance(access.get("sources")) elif binding.get("kind") == "workflow": try: producer = analysis_artifact_metadata(binding)["analysis_producer"] @@ -901,7 +919,7 @@ def load_saved_analysis( access = manifest.get("analysis_access") or {} if not isinstance(access, Mapping) or access.get("version") != ANALYSIS_SOURCE_ACCESS_VERSION: raise AnalysisResultUnavailable("analysis_lineage_invalid") - checked = authorize_analysis_sources(user_id, access.get("sources"), resolver=source_resolver) + checked = _saved_source_provenance(access.get("sources")) else: raise AnalysisResultUnavailable("analysis_lineage_invalid") public_descriptor = {**deepcopy(descriptor), **context} @@ -983,7 +1001,12 @@ def load_orchestration_analysis_input( user_id, descriptor, *, authorize_run=None, load_result=None, source_resolver=None, bounded=False, authorize_only=False, ): - """Read a real run/step result before a terminal assistant message exists.""" + """Read a real run/step result before a terminal assistant message exists. + + The run is the container and is rechecked on every bounded read. Source documents are + provenance only. ``source_resolver`` is accepted for older callers and is not used. + """ + del source_resolver if not isinstance(descriptor, Mapping) or descriptor.get("version") != SAVED_ANALYSIS_VERSION: raise AnalysisResultUnavailable("analysis_lineage_invalid") binding = descriptor.get("binding") @@ -1010,14 +1033,14 @@ def load_orchestration_analysis_input( policy = manifest.get("analysis_access") or {} if not isinstance(policy, Mapping) or policy.get("version") != ANALYSIS_SOURCE_ACCESS_VERSION: raise AnalysisResultUnavailable("analysis_lineage_invalid") - access = authorize_analysis_sources(user_id, policy.get("sources"), resolver=source_resolver) + access = _saved_source_provenance(policy.get("sources")) context = {"producer": dict(binding), "result_sha256": reference["sha256"]} if authorize_only: return None, deepcopy(descriptor) if bounded: def reauthorize(): (authorize_run or _authorize_orchestration_producer)(user_id, binding) - return authorize_analysis_sources(user_id, policy.get("sources"), resolver=source_resolver) + return access return SavedAnalysisInput(manifest, load, access, context, reauthorize=reauthorize), deepcopy(descriptor) return _analysis_explanation_input(manifest, load, access, context), deepcopy(descriptor) @@ -1698,7 +1721,7 @@ def read_saved_analysis_diagnostics( or (end < page["total_bytes"] and not content_bytes) ): raise ValueError("The diagnostic transport range is incomplete or invalid.") - # Close source/masking/deletion changes during a storage read before returning bytes. + # Close masking, deletion and conversation-access changes during a storage read before returning bytes. load_saved_analysis(user_id, context, **read_options) response = { **page, @@ -1789,7 +1812,11 @@ def read_saved_analysis_page( def sanitize_saved_analysis_messages( messages, user_id, *, result_reader=None, workflow_result_reader=None, conversation_reader=None, ): - """Withhold result-derived content on new reads after source access is lost.""" + """Withhold result-derived content when the saved result's container or lineage fails. + + Source documents are not rechecked: a result stays readable in its conversation after + the documents it came from change, are deleted or are held. + """ read = result_reader or load_saved_analysis lineage = None sanitized = [] diff --git a/application/single_app/functions_tabular_generated_exports.py b/application/single_app/functions_tabular_generated_exports.py index 32c5706bb..13aafbf64 100644 --- a/application/single_app/functions_tabular_generated_exports.py +++ b/application/single_app/functions_tabular_generated_exports.py @@ -3028,9 +3028,8 @@ def _delete_blob_if_exists(blob_path): blob_client.delete_blob() -def _authorize_tabular_export_run_execution( - run, *, require_current_sources=True, require_active_producer=None, -): +def _authorize_tabular_export_run_conversation(run): + """The run's conversation is the container for the exports it produces.""" user_id = str((run or {}).get('user_id') or '').strip() conversation_id = str((run or {}).get('conversation_id') or '').strip() if not user_id or not conversation_id: @@ -3045,6 +3044,20 @@ def _authorize_tabular_export_run_execution( raise PermissionError('Export conversation no longer exists') from exc if str(conversation.get('user_id') or '').strip() != user_id: raise PermissionError('Export conversation ownership changed') + return conversation + + +def _authorize_tabular_export_run_execution( + run, *, require_current_sources=True, require_active_producer=None, +): + """Authorize work that still reads the export's source file as an input. + + The sources the originating chat request consumed (``screening_sources``) are + provenance and are not checked again. + """ + conversation = _authorize_tabular_export_run_conversation(run) + user_id = str((run or {}).get('user_id') or '').strip() + conversation_id = str((run or {}).get('conversation_id') or '').strip() if run.get('execution_policy') == 'data_only' or run.get('compute_context') is not None: if require_active_producer is None: require_active_producer = run.get('status') != 'completed' @@ -3062,7 +3075,6 @@ def _authorize_tabular_export_run_execution( return conversation elif run.get('execution_policy') not in (None, 'publish'): raise ValueError('The native execution policy is invalid.') - assert_evidence_available((run or {}).get("screening_sources"), user_id=user_id) source_authorization = ( (run or {}).get('source_descriptor') @@ -3119,9 +3131,7 @@ def _authorize_tabular_export_run_execution( if not authorized: raise PermissionError('Export source is no longer authorized') - assert_evidence_available( - [(run or {}).get("screening_sources"), source_authorization], user_id=user_id, - ) + assert_evidence_available(source_authorization, user_id=user_id) if container_name and blob_path: assert_blob_available(container_name, blob_path, user_id=user_id, purpose="export") return conversation @@ -7750,7 +7760,12 @@ def _screened_run_public_status(run): if not any(run.get(key) for key in ("source_descriptor", "source_authorization", "screening_sources")): return None try: - _authorize_tabular_export_run_execution(run) + if str(run.get('status') or '').strip().lower() == TABULAR_EXPORT_STATUS_COMPLETED: + # A finished export is generated content: its status follows its conversation, + # not the file it was generated from. + _authorize_tabular_export_run_conversation(run) + else: + _authorize_tabular_export_run_execution(run) except (ScreeningError, LookupError, PermissionError) as error: message = error.public_message if isinstance(error, ScreeningError) else "Source content is unavailable." return { diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index 8fa33177d..a7ed73f29 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -625,7 +625,9 @@ def _prepare_conversation_context_for_invocation( model_endpoint_id=None, selected_agent=None, ): - assert_evidence_available([conversation_history, agent_citations]) + # Current-turn tool citations were read in this request; earlier history is + # conversation content and is not rechecked against its sources. + assert_evidence_available(agent_citations) assert_current_request_sources_available() agent_fields = _get_conversation_context_agent_fields(selected_agent) context_snapshot = build_conversation_context_snapshot( @@ -2638,7 +2640,7 @@ def _read_recent_assistant_messages(conversation_id, message_limit): def _sanitize_saved_analysis_history(messages, user_id=None): - """Apply source access before summaries, citations, or model context are built.""" + """Apply saved-result access before summaries, citations, or model context are built.""" messages = list(messages or []) if not any( any((message.get('metadata') or {}).get(key) for key in ( @@ -2837,11 +2839,11 @@ def _load_prior_turn_function_results(user_id, conversation_id, settings=None): try: _authorize_personal_conversation_access(normalized_user_id, normalized_conversation_id) + # Earlier turns' tool results are conversation content; their sources are not rechecked. assistant_messages = _read_recent_assistant_messages( normalized_conversation_id, _resolve_prior_turn_history_window(settings), ) - assert_evidence_available(assistant_messages, normalized_user_id, cached=True) selected_citations = select_prior_turn_action_citations( assistant_messages, )[:PRIOR_TURN_ACTION_RESULT_ARTIFACT_LIMIT] @@ -2862,7 +2864,6 @@ def _load_prior_turn_function_results(user_id, conversation_id, settings=None): artifact_payload = artifact_payload_map.get(str(citation.get('artifact_id') or '')) stored_citation = artifact_payload.get('citation') if isinstance(artifact_payload, dict) else None resolved_citations.append(stored_citation if isinstance(stored_citation, dict) else citation) - assert_evidence_available([assistant_messages, resolved_citations], normalized_user_id, cached=True) return resolved_citations except ScreeningError: raise @@ -15875,7 +15876,7 @@ def execute_saved_analysis_chat_request(data, publish_background_event=None, can message = 'The saved analysis could not be explained. Its saved data is unchanged.' status = 503 if isinstance(exc, PermissionError): - message = 'This analysis is unavailable because its source access could not be confirmed.' + message = 'This analysis is unavailable because access to its conversation or saved result could not be confirmed.' status = 403 elif isinstance(exc, (WorkflowContextBudgetError, WorkflowResultNotReadyError, SavedAnalysisFollowupUnsupported)): message = str(exc) @@ -16753,7 +16754,7 @@ def execute_document_action_chat_request( ) return { 'error': ( - 'This analysis is unavailable because its source access could not be confirmed.' + 'The analysis could not be saved because this conversation or a selected document is no longer available.' if isinstance(exc, PermissionError) else 'The analysis completed, but its final data could not be saved.' ), @@ -19039,9 +19040,10 @@ def result_requires_message_reload(result: Any) -> bool: summary_prompt_search += "\n".join(message_texts_search) try: - # Use the already initialized gpt_client and gpt_model + # The summary reads only earlier user and assistant text, so + # only this request's input reads guard the model call. summary_response_search = guard_model_callable( - gpt_client.chat.completions.create, last_messages_asc, user_id, + gpt_client.chat.completions.create, (), user_id, )( model=gpt_model, messages=[ @@ -28225,7 +28227,7 @@ def should_apply_history_grounding_message( def build_assistant_history_content_with_citations(message, content): - assert_evidence_available(message, cached=True) + # A stored reply and its citations are conversation content; their sources are not rechecked. base_content = str(content or '').strip() citation_sections = [] @@ -28392,13 +28394,8 @@ def build_conversation_history_segments( recent_messages = ordered_messages[-num_recent_messages:] if num_recent_messages else [] older_messages_to_summarize = ordered_messages[:num_older_messages] - reused_messages = recent_messages + (older_messages_to_summarize if enable_summarize_older_messages else []) - assert_evidence_available([ - message for message in reused_messages - if message.get("role") == "assistant" - and (message.get("metadata") or {}).get("thread_info", {}).get("active_thread") is not False - and not (message.get("metadata") or {}).get("masked") - ], cached=True) + # Earlier replies are reused as conversation content without rechecking their sources. + # Workspace attachments sent to the model are input reads of current documents. assert_evidence_available([ message for message in recent_messages if message.get("workspace_document_id") @@ -28449,8 +28446,10 @@ def build_conversation_history_segments( if message_texts_older: summary_prompt_older += "\n".join(message_texts_older) try: + # Older user and assistant text is conversation content; only this + # request's input reads guard the summary model call. summary_response_older = guard_model_callable( - gpt_client.chat.completions.create, older_messages_to_summarize, + gpt_client.chat.completions.create, (), )( model=gpt_model, messages=[ diff --git a/application/single_app/route_enhanced_citations.py b/application/single_app/route_enhanced_citations.py index 664c5dddd..97b3f8667 100644 --- a/application/single_app/route_enhanced_citations.py +++ b/application/single_app/route_enhanced_citations.py @@ -93,12 +93,11 @@ def _get_authorized_chat_artifact_message(user_id, conversation_id, message_id): # unreachable for every caller, including the participant who requested it. assert_generated_file_approval_allows_download(user_id, message_item) assert_generated_chat_artifact_is_published_for_user(user_id, message_item) - # A workspace link names the active representation; its retained chat blob is not the source to read. - evidence = { - key: value for key, value in message_item.items() - if not message_item.get("workspace_document_id") or key not in {"blob_container", "blob_path"} - } - assert_evidence_available(evidence, user_id) + # A generated file takes its access from this conversation, so the documents it was + # made from are not rechecked. A workspace link names the active representation, + # and reading that workspace document is an input read. + if message_item.get("workspace_document_id"): + assert_document_available(message_item["workspace_document_id"], user_id, purpose="chat_file") return message_item @@ -584,7 +583,10 @@ def get_enhanced_citation_tabular(): # approver: a plain group User can create a group shared conversation while the # approvers are that group's Owner, Admin, and Document Manager roles. assert_generated_file_approval_allows_download(user_id, file_msg) - assert_evidence_available(file_msg, user_id) + if not (file_msg.get('metadata') or {}).get('is_generated_chat_artifact'): + # An uploaded chat file is an input read. A generated file takes its access + # from this conversation, so its sources are not rechecked. + assert_evidence_available(file_msg, user_id) if file_content_source != 'blob': return jsonify({"error": "File is not stored in blob storage"}), 400 @@ -820,7 +822,7 @@ def download_chat_artifact(): except ScreeningError as exc: return jsonify({"error": exc.public_message, "error_code": exc.code}), exc.status_code except PermissionError: - return jsonify({"error": "You no longer have access to this artifact or its sources."}), 403 + return jsonify({"error": "You no longer have access to this artifact."}), 403 except (LookupError, ResourceNotFoundError): return jsonify({"error": "The artifact content is unavailable. Refresh the conversation and try again."}), 404 except ValueError: diff --git a/application/v2_ui/src/components/chat/OrchestrationOutputs.tsx b/application/v2_ui/src/components/chat/OrchestrationOutputs.tsx index 9b9cd6268..aebc3a24e 100644 --- a/application/v2_ui/src/components/chat/OrchestrationOutputs.tsx +++ b/application/v2_ui/src/components/chat/OrchestrationOutputs.tsx @@ -78,7 +78,7 @@ function OutputCard({ // could act on, or a scheduled time they could not otherwise see, earns a sentence. const note = accessDenied ? 'Current access could not be verified. Downloads and retries are withheld.' : output.available === false && !output.error_code && output.state !== 'cancelled' - ? 'This file is unavailable under current source access or screening. Other ready files remain available.' + ? 'This file is unavailable because current access could not be confirmed. Other ready files remain available.' : output.available === false || output.state === 'failed' || output.state === 'cancelled' ? output.message || STATUS_FALLBACK : output.state === null ? STATUS_FALLBACK : ''; diff --git a/docs/explanation/features/ANALYZE_RESULTS.md b/docs/explanation/features/ANALYZE_RESULTS.md index aa99c511e..a31fc2336 100644 --- a/docs/explanation/features/ANALYZE_RESULTS.md +++ b/docs/explanation/features/ANALYZE_RESULTS.md @@ -1,10 +1,14 @@ # Saved Analyze results -**Version: 0.261.191** +**Version: 0.261.232** Implemented in version: **0.261.109**, recorded in `application/single_app/config.py`. +Saved results take their access from their conversation or run, rather than from +their source documents, in **0.261.232**. See +[Upload-only content screening](UPLOAD_ONLY_CONTENT_SCREENING.md). + Planning, download, and responsive stabilization updated in version: **0.261.115**. See [Analyze stabilization](../fixes/ANALYZE_STABILIZATION_FIX.md). @@ -41,8 +45,8 @@ second workflow engine. | `functions_document_analysis_results.py` | Candidate identity, evidence binding, deterministic collection, source coverage, and readable report formatting. | | `functions_workflow_results.py` | Separate final outputs from presentation/diagnostics and identify the exact output consumed by a later task. | | `functions_workflow_result_store.py` | Immutable JSON sections using the existing Blob/Cosmos infrastructure and real workflow, chat, or orchestration identities. | -| `functions_saved_analysis.py` | Authorized result reading, complete-record pages, saved-result explanations, and source-bound history. | -| `functions_analysis_access.py` | Current access to every contributor, including large selections resolved in bounded source batches. | +| `functions_saved_analysis.py` | Authorized result reading, complete-record pages, saved-result explanations, and conversation-bound history. | +| `functions_analysis_access.py` | Source snapshots kept as provenance, and current access to every document an in-progress Analyze reads, including large selections resolved in bounded source batches. | | `functions_analysis_deliverables.py` and `functions_tabular_transformations.py` | Public output projection and explicitly declared deterministic calculations. | | `functions_artifact_publication.py` | Publish existing artifact bytes to an explicit destination, preserving approvals and retry receipts. | @@ -107,9 +111,10 @@ partial findings retain partial completeness on every output; an explicit partial read does not make a downstream result complete. `SavedAnalysisInput.read_report_text()` reads the complete `text` section and -checks its producer, contract, output name, and current source access. It never -reads `presentation.summary`. The generic readers recheck source revisions, -access and screening after restart as well as during consumption. +checks its producer, contract and output name, and the conversation or run that +holds it. It never reads `presentation.summary`. The generic readers recheck the +producer, conversation, run and result integrity after restart as well as during +consumption. They do not read the source documents again. The pure `functions_orchestration_execution_policy.py` module provides `orchestration_file_policy(allow_generated_files=False)` for Gather/Reason. @@ -453,11 +458,15 @@ change its saved findings. ## Access and publication -New reads and reuse require current access to every contributing source. If a -source becomes unavailable to the reader, the entire original result and its -derived explanations, evidence, and original exports become unavailable. This -also applies to workflow result readers and mixed-source results. A conversation -permission alone does not override that rule. +A saved result takes its access from its container. A chat result belongs to its +conversation, an orchestration result to its conversation and run, and a workflow +result to its workflow and run. Anyone who can open the container can read the +result, its evidence, explanations and original exports, even if a contributing +document is later deleted, re-uploaded or held for content screening. The source +list stored with the result is provenance, not an access check. Lineage and +integrity failures still make the result unavailable. Reading a document again, +for example in a new Analyze, is an input read: it requires current access, and +the document must not be held. Workspace publication is separate from saving a result in its originating chat. Passing required checks makes a projection eligible; it does not authorize a @@ -490,7 +499,7 @@ Previously delivered or downloaded bytes cannot be recalled. Original chat artifacts use their own authorized byte reader rather than the workspace-document citation reader. Conversation participation, generated-file -approval, publication state, and saved-source access are checked before and +approval, publication state, and the saved result's lineage are checked before and after reading the persisted blob reference. Changed identity, revision, or recorded content digest prevents delivery. Legacy artifacts without a saved analysis binding retain their existing authorization contract. @@ -504,9 +513,10 @@ reuse after an authorization change. Functional coverage includes source fixtures at 1, 10, 100, 300, and 500 inputs, candidate conflicts and replay, exact result reload, declared calculation -precision, complete-record paging, source revocation, and publication -authorization/retry behavior. Scale fixtures use supported configured limits; -they do not raise default limits for users. +precision, complete-record paging, results that stay readable after their sources +are deleted, re-uploaded or held, and publication authorization/retry behavior. +Scale fixtures use supported configured limits; they do not raise default limits +for users. The local browser tests exercise both chat interfaces and the classic workflow publication controls, including keyboard/mobile behavior and @@ -518,16 +528,21 @@ unavailable results. Principal regression files include `test_orchestration_internal_analysis.py` exercises the real adapter, native producer, work-unit checkpoints, saved reader, and generic facade/store with external I/O doubles. It checks final-record/full-report survival after restart, -zero managed uploads, heterogeneous and partial findings, report absence, -source revocation/screening, cancellation and failed result commits. +zero managed uploads, heterogeneous and partial findings, report absence, source +changes during execution, results that keep their run's access after their +sources change, follow-ups that read an earlier run's sources at their current +version, cancellation and failed result commits. +`test_saved_results_container_access_chat_orchestration.py` checks that saved and +generated results survive deleted, re-uploaded and held sources while held +uploads are still refused as inputs. `test_orchestration_file_policy.py` checks async/thread isolation and real managed-operation guards in fresh normal and optimized interpreters. `test_orchestration_publication_policy.py` covers direct publication, approval and reconciliation denial with zero side effects, read-only observation, default-allow compatibility, and real cold web/scheduler imports. `test_orchestration_artifact_publication.py` covers real retained-result rendering, -upload, source authorization, manual promotion and workspace approval without -workflow receipts, including retries and source revocation. +upload, manual promotion and workspace approval without workflow receipts, +including retries and a disabled render capability. `test_orchestration_external_configuration_capture.py` exercises real web and source-review engines with external I/O doubles, checking invocation-time capture, all producer identity fields, isolated execution settings, planner binding, diff --git a/docs/explanation/features/CONTENT_SCREENING_FRAMEWORK.md b/docs/explanation/features/CONTENT_SCREENING_FRAMEWORK.md index 89a2afd66..8390321ee 100644 --- a/docs/explanation/features/CONTENT_SCREENING_FRAMEWORK.md +++ b/docs/explanation/features/CONTENT_SCREENING_FRAMEWORK.md @@ -4,9 +4,11 @@ Content screening creates an admission checkpoint between document extraction and usable workspace knowledge. It combines deterministic rules with optional model-based inspection and keeps a document unavailable while a required scan or review is outstanding. +**Scope:** since **0.261.230**, screening checks only documents that enter a workspace by user upload or File Sync; see [What gets screened](#what-gets-screened). Files attached in chat count as user uploads because they are saved as `chat_upload` documents. Since **0.261.232** ([#1621](https://github.com/microsoft/simplechat/issues/1621)), content generated from those documents is not checked again when it is read either. This includes model replies and their stored citations, Analyze, orchestration and workflow results, and generated files and exports. Saved results take their access from their container: the conversation, orchestration run or workflow. The check that remains is the input check. When chat, search or an orchestration reads an uploaded document, the reader must still have access and the document must not be held. See [Upload-only content screening](UPLOAD_ONLY_CONTENT_SCREENING.md). + **Implemented in version: 0.261.106.** The application version is managed in `application\single_app\config.py`. -**Current documentation version: 0.261.230.** Upload-only intake was implemented in 0.261.230: documents SimpleChat generates are never screened, and metadata edits, including model-generated metadata, apply without a new hold. Gather / Reason / Render orchestration integration with chat checkpoints was implemented in 0.261.131. Chat checkpoints and strict retained-result source-authority errors were implemented in 0.261.127; enabled-empty policy configuration was implemented in 0.261.114; classic/V2 policy-editor alignment was implemented in 0.261.108; the original framework implementation remains 0.261.106. +**Current documentation version: 0.261.232.** Saved and generated results take their container's access in 0.261.232. Upload-only intake was implemented in 0.261.230: documents SimpleChat generates are never screened, and metadata edits, including model-generated metadata, apply without a new hold. Gather / Reason / Render orchestration integration with chat checkpoints was implemented in 0.261.131. Chat checkpoints and strict retained-result source-authority errors were implemented in 0.261.127; enabled-empty policy configuration was implemented in 0.261.114; classic/V2 policy-editor alignment was implemented in 0.261.108; the original framework implementation remains 0.261.106. **Dependencies:** Enhanced Citations and its configured storage account, the existing Cosmos DB and workspace knowledge services, and an approved model connection when a policy includes model evaluation. @@ -28,7 +30,7 @@ New enrolled documents are held before ordinary processing is queued. Their sour The document record contains a small screening marker. Detailed evidence and canonical content live in private artifacts in the Enhanced Citations storage account; policy, scan, job, and audit metadata live in the screening repository. Normal application settings do not contain the detailed finding payload. -Cosmos state is authoritative. Search projections, cached results, native table reads, source previews, historical citations, and repeated source-context use must honor the current document decision. A stale index entry or cached snippet is not permission to use the document. +Cosmos state is authoritative for every input read. Search projections, cached search results, native table reads, source previews, file bytes, opening a cited document and any later read of a document as an input must honor the current document decision. A stale index entry or cached snippet is not permission to read the document. Content already generated from a document is different: replies, stored citation excerpts, saved results and generated files keep their container's access and are not checked again. Release also records the exact approved Blob and content fingerprints. Restoring a document marker without its completed scan does not create a valid clearance. Since **0.261.230**, editing a released document's title, abstract, tags or other metadata keeps its clearance: the edit applies directly, is not screened, and does not start a new hold. A rename still cannot change a screened file's extension, and tag edits never rewrite the released Blob's metadata, because the release is bound to that Blob's ETag and content hash. Ordinary lists return status-only metadata when release proof is unavailable. @@ -67,20 +69,11 @@ Reviewers can accept findings with a reason, remove a page or segment, remove an A clean-looking retry does not silently erase an unresolved finding. Review expiry, a disabled feature, or a changed rule is not an approval. -### Retained-result authority failures - -**Implemented in version: 0.261.127** for [#1509](https://github.com/microsoft/simplechat/issues/1509); version tracking remains in `application\single_app\config.py`. - -A temporary Cosmos or authority-service outage is not evidence that a previously authorized source was revoked or placed under review. Retained orchestration results use a strict server-owned access path so discovery and output retries fail explicitly instead of silently omitting those results. The path still reads current document metadata, permissions and screening release proof; it never uses a saved manifest or cached positive decision as authority. +### Input reads and the model fence -| Outcome | Strict service behavior | -| --- | --- | -| Transport timeout, connection failure, HTTP 408/429 or 5xx | `SourceAuthorityUnavailableError`, code `source_authority_unavailable`, `retryable=True`. | -| Malformed authority response or a nontransient backend/configuration failure | `SourceAuthorityUnverifiedError`, code `source_authority_unverified`, `retryable=False`. | -| Existing typed screening error | Preserve its type and stable code; configuration/validation/conflict errors are not retryable. | -| Current denial, missing document, known hold, invalid release proof or snapshot conflict | Continue to refuse access; no fallback to retained content or a model. | +The hold applies whenever chat, search or an orchestration reads a document as an input. This covers search retrieval, document selection, file bytes and previews, opening a cited document, workspace attachments in chat history, and orchestration or Analyze steps that read documents. A held document, a document the reader can no longer open, or a version change during a read still in progress is refused. -Bootstrap owners bind `resolve_orchestration_source_manifest(requested_sources, user_id, ...)` and `read_orchestration_source_metadata(document_id, user_id, group_id=None, public_workspace_id=None)` from `functions_orchestration_source_access.py`. The resolver accepts the existing mixed-source selection, conversation, active-scope and cancellation arguments. `OrchestrationResultAccess.authorize_sources` uses the strict helper for fresh manifest and metadata checks, including injected runtime readers. Strict search resolution bypasses the legacy document reader's `None`-on-error fallback without changing ordinary search or workflow defaults. +Orchestration input selection uses `resolve_orchestration_source_manifest` and `read_orchestration_source_metadata` from `functions_orchestration_source_access.py`. They report typed failures inside `strict_source_authority()`. A transient authority outage raises the retryable `SourceAuthorityUnavailableError`, and a malformed or non-transient failure raises `SourceAuthorityUnverifiedError`. Neither is reported as a revoked or held document. Steps that read documents themselves call `verify_orchestration_input_sources` through `OrchestrationResultAccess.authorize_input_sources`. The additive `assert_document_available(..., strict_errors=True)` and `assert_evidence_available(..., strict_errors=True)` APIs are server-only choices, not request settings. A headless owner that catches source errors must wrap its entire source decision/model phase in `with strict_source_authority():`; nested checks retain the first failure until that operation ends. Flask requests additionally retain the existing request-local model fence. Independent operations do not share a global failure flag. @@ -90,6 +83,8 @@ Only `public_message`, `code`, `retryable` and `status_code` are suitable for pu Importing the source-access contracts does not import telemetry, settings or configuration owners. Runtime failure reporting resolves `log_event` only after recording the model fence; import-order checks cover both normal and optimized Python without provider access. +The model fence (`strict_source_authority`, `guard_model_callable`, `assert_current_request_sources_available`) blocks every later model call in a request or orchestration step after one of these input reads fails, even when the caller caught the error. Replaying stored replies and citations, and reading retained or saved results, never trips it. + ## Clean derivatives Remediation changes canonical knowledge and produces a clean text or structured-data derivative. It does not draw a box over text and call the original file redacted. @@ -110,7 +105,7 @@ Where necessary, an existing source is extracted again to obtain canonical conte Cancellation does not release a document already being inspected. Concurrent source changes, a new required policy, or a stale reviewer decision invalidate the old release attempt. -Source-bound conversation history and exports withhold unavailable attachments and tool evidence. Remediated workspace attachments are refreshed from the current clean representation. Legacy native tool results that have no matching revision proof must be regenerated rather than borrowing a later approval. +Conversation history and exports keep AI replies, tool results and their stored citations, because they are conversation content and take the conversation's access. A workspace attachment shown in history is an input read. While its document is held, deleted or no longer accessible, the attachment is replaced with a placeholder. A remediated attachment is refreshed from the current clean representation. Automatic metadata generation uses admitted source content. Since **0.261.230**, the metadata it generates is applied to the released document directly, the same way as a user's metadata edit, without a new hold or scan. @@ -130,7 +125,7 @@ Configured-check summaries count enabled local and mandatory baseline rules/mode Since **0.261.114**, enabling and saving the feature no longer requires selecting checks first. Enabled empty policies can be saved, and the summary explains that new uploads are not screened until applicable checks are added. The classic toggle persists immediately; V2 uses **Save changes**. Both editors refresh an automatically created baseline without discarding policy drafts or silently overwriting a concurrent administrator's edits. Detailed policy changes still use **Save screening policy**. Sample testing requires checks to evaluate and never describes an empty policy as a clean inspection. -The **0.261.113** React V2 integration retains these controls alongside unified embedding/image connections and durable Analyze results. Sequential and isolated concurrent Analyze model calls recheck source availability, final coverage retains screening provenance, and completed checkpoints cannot bypass a later hold. Saved-result responses and exports retain both their source-access rules and screening checks. +The **0.261.113** React V2 integration retains these controls alongside unified embedding/image connections and durable Analyze results. Sequential and isolated concurrent Analyze model calls recheck source availability while they read documents, final coverage retains screening provenance, and completed checkpoints cannot bypass a later hold for a read still in progress. Since **0.261.232**, a saved result, its responses and its exports take their access from their conversation or run and are not checked against their sources again. Use the [content-review guide]({{ '/guides/review-screened-documents/' | relative_url }}) for baseline selection, existing-workspace scans, and remediation. The capability is distinct from the existing Azure AI Content Safety chat-category feature. @@ -150,6 +145,8 @@ The core cases include a last-page finding, complete window coverage, regex dead `functional_tests\test_orchestration_source_access.py` exercises strict metadata and scope-service failures, the real mixed-source/search resolver, retained-result discovery and reopen, unchanged legacy defaults, caught-error model fences, concurrent scope isolation and network-blocked cold imports in normal and optimized Python. `test_content_screening_access.py` continues to cover the original default-mode sanitization and model-fallback fence. +Since **0.261.232**, those strict failures apply to input reads only, and retained results reopen without reading their sources. `test_saved_results_container_access_chat_orchestration.py` proves that deleted, re-uploaded or held sources do not hide saved Analyze results, orchestration outputs, generated files, chat replies, stored citations or exports. It also proves that a held upload is still refused as an input, and that the fence still blocks a model call after such a read. + The document adapter covers workspace knowledge, including chat files handed off to a workspace. **0.261.127** adds [chat text checkpoints]({{ '/explanation/features/CHAT_CONTENT_CHECKS/' | relative_url }}) that reuse its global baseline without changing document holds or workspace review. Since **0.261.131**, Gather / Reason / Render orchestration replies use the same output checkpoint before publication, including model-free delivery. Chat-only attachment screening, outbound web-search preflight, and agent-to-agent message inspection remain outside these checkpoints. -Formatting-based hidden-text detection and layout-preserving PDF/Office redaction remain separate work. Neither regex nor a model guarantees that all sensitive information or prompt injection will be found. Previously downloaded content and requests already sent to a provider cannot be recalled. +Formatting-based hidden-text detection and layout-preserving PDF/Office redaction remain separate work. Neither regex nor a model guarantees that all sensitive information or prompt injection will be found. Previously downloaded content and requests already sent to a provider cannot be recalled. Results already generated from a document are not withdrawn when that document is later deleted, rejected or screened again. They remain visible to everyone who can open their conversation, run or workflow. Model-written content and metadata are not screened. diff --git a/docs/explanation/features/DOCUMENT_PROVENANCE.md b/docs/explanation/features/DOCUMENT_PROVENANCE.md index 669351fc6..0ee215367 100644 --- a/docs/explanation/features/DOCUMENT_PROVENANCE.md +++ b/docs/explanation/features/DOCUMENT_PROVENANCE.md @@ -2,6 +2,8 @@ Implemented in version: **0.261.194**. +Provenance described as record-keeping only, never access, in version: **0.261.232**. + Application version tracking: `application\single_app\config.py`. Tracking: issue #1555, Track P of the @@ -245,6 +247,21 @@ The links are V2 routes: `/workspace/workflows?workflow_id=...&run_id=...`, `/groups//workflows?workflow_id=...&run_id=...`, and `/chat?conversationId=...`. +### Provenance is not access + +Since **0.261.232**, provenance describes where content came from. It never +decides who can read that content. Two kinds of provenance are kept for display, +filtering and audit: a document's `origin` record, and the source lists stored +with saved Analyze, orchestration and workflow results. + +- A saved result takes its access from its conversation, orchestration run or + workflow. +- A published document takes its access from its destination workspace. +- Reading a source document again, for example to open a citation or run a new + Analyze, is an input read. It has its own access and screening check. + +See [Upload-only content screening](UPLOAD_ONLY_CONTENT_SCREENING.md). + ### Origin filters on list routes Three query parameters narrow the existing document lists: diff --git a/docs/explanation/features/ORCHESTRATION_CHECKPOINT_RECOVERY.md b/docs/explanation/features/ORCHESTRATION_CHECKPOINT_RECOVERY.md index 337d3ed13..4c98832e5 100644 --- a/docs/explanation/features/ORCHESTRATION_CHECKPOINT_RECOVERY.md +++ b/docs/explanation/features/ORCHESTRATION_CHECKPOINT_RECOVERY.md @@ -1,10 +1,14 @@ # Orchestration Checkpoint Recovery -**Version: 0.261.139** +**Version: 0.261.232** Implemented in version: **0.261.105**, recorded in `application/single_app/config.py`. +Since **0.261.232**, restored retained references take their access from their +conversation and run: their source documents are not reread. See +[Upload-only content screening](UPLOAD_ONLY_CONTENT_SCREENING.md). + Gather / Reason / Render retained-reference recovery implemented in version: **0.261.127** (Refs #1509). Same-attempt waiting claims and native/result continuation were added in **0.261.127**. Gather / Reason / Render became the @@ -306,8 +310,9 @@ inputs against the saved digest before opening the original job. Recovery can reuse an independent successful producer after an earlier sibling failed; it no longer assumes that all successful work forms one linear prefix. -Every restored reference is reauthorized against current ownership, sources, -screening and producer state. The original actor, run and attempt on a result +Every restored reference is reauthorized against current ownership, producer +state and lineage integrity. The documents behind it are not reread; a resumed +step that reads documents checks them as inputs when it runs. The original actor, run and attempt on a result are never rewritten. An old-attempt reference is admitted under an exact server-generated alias before a new-attempt consumer can read it. @@ -558,7 +563,7 @@ exception remains server-side in `__cause__`. | `context_unavailable` | The conversation authorizer denied access, recovery data was deleted, or the inherited run is unavailable to the caller. | | `recovery_changed` | Approved inputs or checkpoint lineage no longer match. | | `ownership_lost` | The current execution no longer owns its guarded writes. | -| `result_unavailable` | Retained source access was denied, integrity or ownership validation failed, or a definite screening hold applies; no preview may replace it. | +| `result_unavailable` | Ownership, producer or integrity validation failed, or a document the step reads as an input is denied or held; no preview may replace it. | Since **0.261.209**, external-source identity comes from the signed-in session, which a scheduler continuation doesn't have. A step, saved wait or final-answer @@ -698,12 +703,13 @@ saved StepResult passed to the single shared helper. Run-wide file aggregation retains the owning service's current public file-visibility contract; it is separate from the saved-step resumer. A completed file can be currently unavailable without changing its immutable -commit. Genuine source -denial or a screening hold retains its safe requested-file metadata, clears -links and counts, and leaves accessible siblings visible. Restoring access +commit. A file whose retained result, run or capability is unavailable retains +its safe requested-file metadata, clears links and counts, and leaves accessible +siblings visible. Changes to the documents behind its retained result do not make +it unavailable. Restoring access reopens the same committed file without rendering or generating content again. Storage/network failures, screening-service or authority-configuration failures, -and missing required source readers propagate instead of becoming denied-file +and missing required readers propagate instead of becoming denied-file placeholders. The saved-step checkpoint and pending wait remain recoverable; observing a read failure does not spend another output attempt. Coverage is in `test_orchestration_render_read_failures.py`, implemented in **0.261.127** and @@ -733,11 +739,12 @@ checkpoint, then inject initialized services, the actual owning guard and `input_fingerprint`, not a browser value or a fresh guess. This entrypoint compares current declared inputs with that saved fingerprint, -rechecks capability admission, ownership, source access and the attempt +rechecks capability admission, ownership, access to the documents the native job +is still reading, and the attempt deadline, and opens the existing native handle once. It neither selects a model nor builds or submits another native request. Full readers are retained before a completed result is returned; changed sources, lost access, stale -guards and incomplete results fail closed. +guards and incomplete results fail closed because the read is still in progress. The entrypoint returns a typed `StepResult` for a verified native outcome; it does not mutate the caller's pending task map or commit orchestration @@ -776,8 +783,8 @@ forbidden throughout the refresh. If a private complete or partial result committed before its completion checkpoint was saved, recovery opens the guarded input checkpoint and calls `recover_task_result(producer=..., input_fingerprint=...)` with its original -identity. The facade verifies the exact receipt, current ownership/access, -screening and the full retained output contents. Recovery reconstructs a +identity. The facade verifies the exact receipt, current ownership and producer +state, and the full retained output contents. Recovery reconstructs a reference-only completion payload without invoking an adapter or model. The active attempt commits that payload through its existing lease; child and chained retries retain the original producer and explicitly authorized aliases. diff --git a/docs/explanation/features/ORCHESTRATION_OUTPUT_LIFECYCLE.md b/docs/explanation/features/ORCHESTRATION_OUTPUT_LIFECYCLE.md index d6d281bcf..7a131611f 100644 --- a/docs/explanation/features/ORCHESTRATION_OUTPUT_LIFECYCLE.md +++ b/docs/explanation/features/ORCHESTRATION_OUTPUT_LIFECYCLE.md @@ -4,6 +4,8 @@ **Updated in version: 0.261.134** (each run attempt owns its outputs) +**Updated in version: 0.261.232** (a file takes its access from its conversation and run; the documents behind its retained result are not reread. See [Upload-only content screening](UPLOAD_ONLY_CONTENT_SCREENING.md)) + **Application version owner:** `application/single_app/config.py` **Issue:** microsoft/simplechat#1509 @@ -93,7 +95,8 @@ separate. 4. Persist an immutable byte intent before transport. It includes a deterministic per-intent idempotency key and private artifact address. 5. Upload bytes and create an immutable file message privately. Reauthorize - ownership, sources, screening, capability, deadline, and lease throughout. + ownership, the retained result's producer and run, capability, deadline, and + lease throughout. The documents behind the retained result are not reread. 6. Verify the staged message and complete blob, then reauthorize again. A same-partition conditional batch compares the parent run and output ETags and commits **one output's** exact intent. @@ -109,9 +112,10 @@ logical output ID remains unchanged. Only one intent can become visible. A late write to an abandoned intent cannot replace committed bytes. Successful outputs are immutable. Stop preserves already committed siblings; -explicit output deletion withdraws that output. Source deletion, screening, -ownership changes, superseded work, and deleted result/run records still deny -downloads and history even when a previous commit exists. +explicit output deletion withdraws that output. Ownership changes, superseded +work, and deleted result/run records still deny downloads and history even when a +previous commit exists. Deleting, re-uploading or screening a document behind the +retained result does not: the file keeps its conversation's and run's access. When an attempt reuses earlier staged bytes, the byte intent keeps its original render-attempt binding, while the output separately records the committing attempt. Earlier failed automatic attempts remain failed in the audit history. @@ -123,26 +127,26 @@ at most two automatic retries. Counts include durable admissions, so an attempt can appear as admitted while waiting for its worker. Reloading, duplicate calls, polling, and process replacement do not reset them. -Transient transport/storage failures, typed current-source authority or screening outages, -and explicitly retryable renderer I/O failures use bounded exponential backoff -with jitter. Safe numeric service +Transient transport/storage failures and explicitly retryable renderer I/O +failures use bounded exponential backoff with jitter. Safe numeric service retry guidance up to 300 seconds is honored, subject to the original deadline. -Access denial, screening holds, partial/invalid results, unsupported +Access denial, partial/invalid results, unsupported formats/profiles/options, deterministic layout/schema errors, cancellation, deletion, and supersession do not automatically retry unchanged. -`SourceAuthorityUnavailableError` uses the same bounded retry cycle; -`SourceAuthorityUnverifiedError` indicates a non-transient authority problem -and does not automatically retry. Public output and history reads propagate -both typed failures instead of presenting them as source denial or stale success. +Retained-result reads no longer call document authority services, so a render +does not meet `SourceAuthorityUnavailableError` or `SourceAuthorityUnverifiedError` +from its input. The classification is kept for callers that still report them: +the first uses the bounded retry cycle, the second does not automatically retry, +and public output and history reads propagate both instead of presenting them as +a denial or stale success. Current directory/token failures preserve `ExternalIdentityServiceError.code` and its declared retryability. Throttling, timeouts, incomplete responses and temporary service failures use the same three-attempt automatic budget. Malformed responses, pagination/size limits and invalid authority callbacks are nonretryable and return no authorized data. Neither category is proof of -revoked access. Only actual `ResultUnavailableError` denial or -`DocumentHeldError` screening holds are treated as such; other screening -exceptions remain configuration/service failures. Failure progress retains +revoked access. Only an actual `ResultUnavailableError` denial of the +conversation, run or retained result is treated as such. Failure progress retains durable admission/retry facts but withholds rendered counts and artifact IDs when current authority could not be verified. @@ -317,8 +321,9 @@ must revalidate current capability availability and the server-approved render/source admission. It raises a typed denial or returns `False` on denial. Operations are `admit`, `render`, `prepare`, `commit`, `retry`, `read`, and `publication`. It must not trust route/model-supplied actor IDs, paths, source -manifests, or settings. The result service independently rechecks current source -ACLs and screening. +manifests, or settings. The result service independently rechecks the retained +result's producer, conversation and run. It does not reread the documents the +result came from. Register `configure_orchestration_artifact_service(factory)` in the owning web and scheduler bootstrap. `factory(user_id, conversation_id)` rebuilds an @@ -541,16 +546,17 @@ owner must preserve the existing checkpoint/wait and report a safe operational error; inability to verify current access is neither permission to replay work nor a terminal denial. No cached success, links or replacement uncertainty DTO are returned. A later successful observation returns freshly authorized facts. -Genuine denial or a document hold, after verifying the exact saved binding, -returns a failed StepResult with the current safe `available=false` projection: -artifact identity, counts and retry controls are withheld. Cancellation remains +Genuine denial of the conversation, run or retained result, after verifying the +exact saved binding, returns a failed StepResult with the current safe +`available=false` projection: artifact identity, counts and retry controls are +withheld. Cancellation remains cancelled, and identity/spec/deadline disagreements remain explicit failures. Changes to current execution limits do not create a new file identity during observation. The same observation path reauthorizes an already-completed file checkpoint before its card is displayed. It works with `context.allow_generated_files=False`: permission to perform file-creation -effects is not needed for observation, while current source access, +effects is not needed for observation, while current conversation and run access, capabilities and publication visibility are still enforced. A completed saved status never makes an unfinished or unavailable current output ready. @@ -571,39 +577,40 @@ routes and history use `service.list_public_outputs(run_id)`, or strict | `can_retry` | Whether another explicit manual admission is currently eligible. | | `error_code`, `message` | Stable safe code and fixed user-facing status text; never exception text. | | `artifact_message_id` | Present only for a committed, currently available output; otherwise `null`. | -| `row_count`, `character_count`, `size_bytes` | Verified rendered counts when available; `null` before preparation or when source access is withheld. | +| `row_count`, `character_count`, `size_bytes` | Verified rendered counts when available; `null` before preparation or when the output is unavailable. | No path, container, source reference, raw manifest, lease token, settings, or provider exception is included. Final assistant publication must use `committed_artifacts`, not saved `run.artifacts`, model-generated links, or staged upload responses. -If one source is revoked, held for screening, deleted, changed under the current -snapshot policy, or no longer has a readable retained result, the list preserves -the other files. The unavailable entry keeps only its safe requested-file +If one file's retained result is no longer readable (for example, the result was +deleted, its producer run was removed, or its committed records are missing), or +its capability is disabled, the list preserves the other files. The unavailable +entry keeps only its safe requested-file description, identity, persisted state, and admission counters. It sets `available=false`, `can_retry=false`, and clears the artifact ID, retry time, and all rendered counts. Its existing `error_code` and `message` fields contain the current stable reason and fixed safe explanation; there is no separate reason field. Restored access restores the committed link without rerendering -or modifying that output's successful record. +or modifying that output's successful record. Changes to the documents behind a +retained result, such as revoked access, a screening hold, deletion or a new +version, do not make a file unavailable. -These overlays distinguish genuine `DocumentHeldError` from screening -configuration/service errors. Storage failures, network failures, missing -source-reader configuration, and unexpected callback failures propagate. -They are not reported as ordinary source denials. Owner/conversation denial +Storage failures, network failures and unexpected callback failures propagate. +They are not reported as an unavailable file. Owner/conversation denial also fails the overall list rather than returning another owner's file metadata. Orchestration file-message and card history use the same infrastructure-failure distinction; legacy workflow history behavior remains unchanged. Bare `PermissionError` raised while reading the output index, output row, parent -run or committed-file metadata is a backing-store fault, not a source-access +run or committed-file metadata is a backing-store fault, not an access decision. Those specific metadata reads translate it to `OutputStorageError` before public projection or delivery can invent a denial. Typed ownership, -deletion and missing-record errors remain unchanged. Source-authorization -callbacks are outside this translation, so actual source denials and screening -holds still produce unavailable-file projections; the context-free permission -classifier is not broadened. +deletion and missing-record errors remain unchanged. Retained-result reads never +call document source services, so no source denial or screening hold can produce +an unavailable-file projection; the context-free permission classifier is not +broadened. Saved Render resumption uses the same metadata-read boundary for its initial output record and current parent-run reads, before checking their immutable bindings. Neither read can bypass the operational-error classification. @@ -656,12 +663,11 @@ and availability are never initial-hydration authority. Top-level orchestration cards are rebuilt from `committed_artifacts`; unrelated artifact kinds retain their existing handling. -Source denial affects only that file's current projection. An unavailable +An unavailable file affects only its own current projection. An unavailable owning run/conversation clears the cached output list and orchestration cards; storage, network, missing service configuration, and unexpected read failures propagate instead of returning cached success. Metadata-only history reads do -not import route owners, and their screening checks restore request-local -screening state so one withheld source does not hide accessible siblings. +not import route owners and do not read the documents behind a retained result. ## Validation and limitations @@ -680,9 +686,9 @@ tests reject ordinary runs, mismatched or replaced guards, missing tokens and readers, and storage outages. Real upload threads cover data arriving after logical deletion and lease grace, and generation checks preserve late cleanup notifications. Committed siblings require their own explicit tombstones. -Card-free assistant history independently exercises current source denial, -screening holds, logical deletion, and infrastructure errors without cached -success or replaying producers. +Card-free assistant history independently exercises source denial and screening +holds that leave the file available, capability disablement, logical deletion, +and infrastructure errors without cached success or replaying producers. `functional_tests/test_orchestration_output_identity.py` verifies every current identity-service error code at rendering, commit, artifact-factory, public output/card and history boundaries, including explicit wrapped causes, @@ -785,10 +791,11 @@ component cleanup-root and scheduler coverage alone does not satisfy it. Coverage includes CSV and JSON from one source, a real PDF report, empty TXT/MD, independent sibling success, exactly three automatic attempts and one idempotent manual admission, restart, duplicate/concurrent/stale workers, -parent lease replacement, deadline and bounded retry guidance, source and -screening revocation, capability disablement, Stop/deletion, integrity failures, +parent lease replacement, deadline and bounded retry guidance, files that survive +source revocation, screening, deletion and new versions, capability disablement, +Stop/deletion, integrity failures, blob-only/message/commit crash points, uncertain acknowledgments, safe history, -independently revoked/deleted/changed source projections, restored access, +restored access, infrastructure-error propagation, bounded admission, cleanup, and owner-free cold imports in normal and optimized Python. Required setup and I/O occur outside assertion expressions. diff --git a/docs/explanation/features/UPLOAD_ONLY_CONTENT_SCREENING.md b/docs/explanation/features/UPLOAD_ONLY_CONTENT_SCREENING.md new file mode 100644 index 000000000..fa01bc364 --- /dev/null +++ b/docs/explanation/features/UPLOAD_ONLY_CONTENT_SCREENING.md @@ -0,0 +1,155 @@ +# Upload-Only Content Screening + +## Overview + +Content screening checks a document when it enters a workspace, and then trusts what is built from it. Screening covers documents that arrive by user upload or File Sync. It does not check, or check again, anything generated from those documents. + +Earlier versions also re-checked every result produced from a screened document against that document, both when the result was saved and each time it was read. These results included saved Analyze results, orchestration and workflow outputs, generated files, chat replies and their citations, history and exports. A single deleted, re-uploaded or re-screened document, an access change, or even a failed lookup could hide a result that had already been produced. This happened even when screening was off. Chat history replaced a whole AI reply with "Source content is unavailable pending document screening and review." Saved Analyze results and orchestration files were withheld. + +**Implemented in version: 0.261.232.** This version completes a stack of changes that began in 0.261.230. Application version tracking remains in `application\single_app\config.py`. + +**Related issue:** [#1621](https://github.com/microsoft/simplechat/issues/1621). Layer 1 also fixes [#1613](https://github.com/microsoft/simplechat/issues/1613). + +**Dependencies:** + +- The [content screening framework](CONTENT_SCREENING_FRAMEWORK.md): policies, holds, review and release proof. +- The existing conversation, orchestration-run and workflow records, which now decide who can open a saved result. +- The `content_screening\access.py` read boundary, which still performs the input check. + +## The model + +### What is screened + +- Documents that users upload to personal, group and public workspaces. +- Files attached in chat. They are saved into the user's workspace as `chat_upload` documents, so they count as user uploads. +- Documents that File Sync brings in. + +Any other way in is screened by default unless it is explicitly marked as generated. Layer 2 adds that marker. + +### What is never checked + +The following content is not screened when it is created, and it is not checked again when it is read: + +- Model replies, including the citation excerpts stored with them. +- Analyze, orchestration and workflow results. +- Generated and merged files, including exports. +- Files that an agent, workflow or orchestration saves or publishes into a workspace (layer 2). +- Model-generated metadata and user edits to metadata (layer 2). + +The source lists recorded with each result are still stored. They are provenance: they tell a reader where the result came from. They are not an access check. + +### Where saved results get their access + +| Result | Who can open it | +| --- | --- | +| Chat AI replies, their stored citations, tool results and conversation exports | Anyone who can open the conversation: its owner, or a participant in a shared conversation. | +| Saved Analyze results from chat, and the generated files projected from them | Anyone who can open the conversation that holds the result. | +| Orchestration retained results, rendered files and orchestration Analyze results | The conversation owner, while the orchestration run that produced them still exists. | +| Workflow results and saved workflow outputs | Anyone with access to the workflow and its run (layer 3a). | + +The container checks are unchanged. If a container check fails, the result is refused. Examples: a reader who is not the conversation owner or a participant; an orchestration run that was deleted; a generated file that is not yet approved or published. + +Lineage and integrity checks are unchanged. A result whose identity, hashes or references don't match is refused. So is a stale browser reference to a result that has since changed. A saved Analyze result whose recorded source list is missing fails as a lineage error. + +### The one input check + +When chat, search or an orchestration reads an uploaded document as an input, two conditions apply: the reader must still be able to open the document, and the document must not be held for screening. This check applies to: + +- search retrieval (`filter_available_results`) +- document selection, including chat uploads selected for a document action (`_resolve_conversation_task_documents`) +- file bytes and previews (`read_available_document_bytes`) +- opening a cited document (`get_document` in `route_enhanced_citations.py`) +- workspace attachments shown in chat history (`refresh_workspace_attachment`) +- orchestration and Analyze steps that read documents themselves (`resolve_orchestration_source_manifest`, `read_orchestration_source_metadata`, `verify_orchestration_input_sources`, `OrchestrationResultAccess.authorize_input_sources`) +- the input guards in document analysis and comparison + +Version checks still protect a read that is in progress. These include resuming an Analyze checkpoint partway through a document, a later step of the same orchestration run reading sources an earlier step named, and finalizing a run whose selected documents changed while it ran. + +A follow-up can read documents that an earlier orchestration run's result named. That read is a normal input check against the current document. A newer version is not an error: the step reads the current version. + +### The model fence + +The model fence is unchanged. It consists of `strict_source_authority`, `guard_model_callable`, `assert_current_request_sources_available` and `_remember_screening_failure`. When an input read in a request or orchestration step finds a held or inaccessible document, the fence blocks every later model call in that operation, even if the caller caught the error. + +Only input reads can trip the fence now. Replaying stored replies and citations into model history, reading retained orchestration results, and reading saved Analyze results never record a failure. + +The same rule applies when a chat request or an orchestration run publishes its answer. Before the answer is saved, the documents that request or run read are confirmed again. If one of them was held or became inaccessible while the work was still running, the answer is not published. A newer version of a document that the run already read does not block publication. + +## Technical specifications + +### Changes in 0.261.232 (layer 3b) + +| Area | File | Change | +| --- | --- | --- | +| History | `content_screening\access.py` | `public_history_messages` never replaces assistant or tool content or stored citations. It refreshes only workspace-backed file and image messages, which are input reads. Generated-file messages keep their container checks through `sanitize_generated_artifact_history`. | +| Chat | `route_backend_chats.py` | Model history no longer re-checks stored replies, prior tool results or citations. Agent citations produced in the current turn are still checked. Saved-analysis follow-up and save errors name the conversation, not source access. | +| Saved Analyze | `functions_saved_analysis.py` | Save and read paths for chat and orchestration results check the container and lineage only. The recorded sources become provenance (`source_snapshot_changed` is always false). | +| Orchestration | `functions_orchestration_results.py`, `functions_orchestration_source_access.py` | Retained results check the producer, conversation and run, lineage, and external sources. They never read workspace sources. `authorize_orchestration_sources` becomes `verify_orchestration_input_sources`, and `authorize_sources` becomes `authorize_input_sources`. Both are used only when a step reads documents. | +| Orchestration | `functions_orchestration_executor.py` | Sources named by an earlier run or attempt are read at their current version after the input check. Sources named earlier in the same attempt must still match. | +| Generated files | `route_enhanced_citations.py`, `functions_generated_artifact_sources.py`, `functions_orchestration_output_store.py` | Downloads, history and promotion check the conversation, approval, publication and destination. A workspace-linked chat file still reads its workspace document as an input. | +| Exports | `functions_tabular_generated_exports.py`, `route_backend_conversation_export.py` | Tabular exports no longer check `screening_sources`. Conversation exports inherit the history split through `public_history_messages`. | +| V2 | `OrchestrationOutputs.tsx` | The unavailable-file note no longer blames the file's sources. | + +### Public messages + +| Situation | Message | +| --- | --- | +| A saved Analyze follow-up cannot confirm the conversation or saved result | "This analysis is unavailable because access to its conversation or saved result could not be confirmed." | +| An Analyze result cannot be saved | "The analysis could not be saved because this conversation or a selected document is no longer available." | +| A generated orchestration file cannot be opened | "This file is unavailable because access to its conversation could not be confirmed." | +| A chat artifact download is refused | "You no longer have access to this artifact." | + +### Configuration + +There are no new settings. `enable_content_screening` and `enable_content_screening_workspace_uploads` keep their meaning. The chat input and output checks (`enable_content_screening_chat_input` and `enable_content_screening_chat_output`) are unchanged and stay off by default. + +## Usage + +Administrators have nothing to configure. The change applies when the stack is deployed. + +What users see: + +- A document that is held, deleted or re-uploaded after it was used no longer hides anything built from it. Replies, citation excerpts, saved Analyze results, explanations, orchestration files and exports stay available to everyone who can open their conversation, run or workflow. +- The held document itself is still refused as an input. Search skips it, and selecting it for a document action is refused. Opening the cited document or downloading its bytes returns the screening hold. A workspace attachment in history shows "Source content is unavailable pending document screening and review." until review completes. +- **Ask about this analysis** explains the saved result without reading the original documents. To review the current version of a document, run a new Analyze on it. + +Example: a user analyzes a contract, and a reviewer later holds the contract after a policy change. The saved findings, their evidence and the generated CSV stay available in the conversation. A new search in the same conversation no longer returns the contract. Selecting it for a new Analyze is refused until review completes. + +## Accepted trade-offs + +- **Results are visible to anyone who can see their container**, even without access to the source documents. In a shared conversation, every participant sees the AI replies and their stored citation excerpts. Opening the full cited document still requires access. Group workflows can only read group documents, so group results stay within the group. +- **Nothing is withdrawn after the fact.** Deleting, rejecting or re-screening a document does not remove results already generated from it. There is no data migration. Results that earlier versions withheld when they were read become visible again. +- **Model-written content and metadata are not screened.** This includes web or M365 text that an agent, workflow or orchestration saves as a workspace file, model-generated metadata, and user edits to metadata. + +## Delivery + +Layers 2 and 3a merged into layer 1's branch, `paullizer-workspace-knowledge-holds`, which merged into `paullizer-react-v2-ui` through #1628. Layer 3b targets `paullizer-react-v2-ui` directly. Layers 1 and 2 ship together as 0.261.230. + +| Layer | Version | Scope | Pull request | +| --- | --- | --- | --- | +| 1 | 0.261.230 | Run history and task output stop re-checking workflow run sources (fixes #1613). | [#1628](https://github.com/microsoft/simplechat/pull/1628) | +| 2 | 0.261.230 | Intake and metadata: generated documents skip screening, and metadata edits no longer put a cleared document back on hold. | [#1629](https://github.com/microsoft/simplechat/pull/1629) | +| 3a | 0.261.231 | Workflow saved results take their access from the workflow and run. | [#1631](https://github.com/microsoft/simplechat/pull/1631) | +| 3b | 0.261.232 | Chat, Analyze, orchestration, generated files, history and exports (this document). | [#1632](https://github.com/microsoft/simplechat/pull/1632) | + +Shared source-check helpers that are still used for input reads, such as `authorize_analysis_sources`, stay in place. Rewording `AnalysisResultUnavailable`, which no longer means a source re-check failed for saved results, is a small follow-up. + +## Testing and validation + +`functional_tests\test_saved_results_container_access_chat_orchestration.py` proves: + +- Deleting, re-uploading or holding a source document, before or after the result was saved, does not hide saved Analyze results, their pages, evidence, explanations, history or generated projections. The source resolver is never called. +- Retained orchestration outputs read every record after a restart with no source reads. +- Chat AI replies and their stored document and tool citations stay in history and in conversation exports. +- Generated chat files take their conversation's access. +- A held uploaded document is still refused by search retrieval, chat-upload document selection, opening a cited document, file bytes, a workspace-linked chat file, a workspace attachment in history, and an orchestration input read. +- Replaying stored replies leaves the model fence open. A held input read closes it, both in a request and in a headless strict scope. +- Container failures (a lost conversation, a deleted run) and integrity failures (stale digests, mismatched identities, missing source lists, uncommitted or corrupted manifests) are still refused. + +Running the new test with the application changes stashed produces 25 failures, which shows that the test detects the old behavior. + +`test_orchestration_internal_analysis.py` covers follow-ups that read sources named by an earlier run at their current version, and still refuses held or revoked inputs. The rewritten tests keep their container, integrity and input cases. They include `test_saved_analysis_*.py`, `test_analysis_saved_source_access.py`, `test_analysis_artifact_publication.py`, `test_analyze_backend_saved_integration.py`, `test_content_screening_history.py`, `test_content_screening_read_boundaries.py`, `test_chat_artifact_download_bytes.py` and `test_orchestration_*`. + +**Performance:** reading a saved result no longer resolves every source document's metadata, permissions and release proof. History no longer reads documents for every stored reply. Each read therefore makes fewer Cosmos calls. + +**Known limitations:** bytes that were already downloaded and requests already sent to a model cannot be recalled. A screening hold applies only to later input reads of the held document. diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index f7c704125..6f54cb128 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,19 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes). +### **(v0.261.232)** + +#### Breaking Changes + +* **Saved Chat, Analyze And Orchestration Results Take Their Container's Access** + * Results built from documents are no longer checked against those documents again. This covers chat AI replies and their stored citations, tool results in history, saved Analyze results, orchestration files and results, generated chat files, and conversation and tabular exports. They stay available to everyone who can open their conversation or orchestration run, even after a source document is deleted, re-uploaded or held for content screening. + * Chat history no longer replaces a whole AI reply with "Source content is unavailable pending document screening and review." when a document it cited changes. A workspace attachment shown in history is still replaced while its document is held or unavailable. + * One input check stays. Search retrieval, document selection (including chat uploads), file bytes and previews, opening a cited document, and orchestration or Analyze steps that read documents still need current access to an uploaded document, and the document must not be held. After such a read is refused, the model fence still blocks later model calls in that request or step. + * A follow-up orchestration step that reads documents named by an earlier run reads their current version instead of failing. Version checks still protect a read in progress, such as a resumed Analyze checkpoint. + * Container, lineage and integrity checks are unchanged. Nothing is withdrawn after the fact, and no data migration runs: results that earlier versions withheld when they were read become visible again. Error messages now name the conversation or saved result instead of source access. + * **Migration**: none. Deployments that relied on source re-checks to hide existing results from people who can open their conversation should review shared conversations; opening the full cited document still requires access. + * (Ref: #1621, `content_screening/access.py`, `functions_saved_analysis.py`, `functions_orchestration_results.py`, `functions_orchestration_source_access.py`, `functions_orchestration_executor.py`, `route_backend_chats.py`, `route_enhanced_citations.py`, `functions_tabular_generated_exports.py`, `OrchestrationOutputs.tsx`, `test_saved_results_container_access_chat_orchestration.py`, [Upload-Only Content Screening](features/UPLOAD_ONLY_CONTENT_SCREENING.md)) + ### **(v0.261.231)** #### Breaking Changes diff --git a/docs/guides/analyze-results.md b/docs/guides/analyze-results.md index 8f776f42f..539f68567 100644 --- a/docs/guides/analyze-results.md +++ b/docs/guides/analyze-results.md @@ -4,7 +4,7 @@ title: "Read and discuss saved Analyze results" description: "Read the answer first, browse saved findings and evidence, and ask follow-up questions without requesting another source pass." section: "Guides" audience: user -version: "0.261.115" +version: "0.261.232" --- ## What this does @@ -65,7 +65,8 @@ download actions, such as **Download CSV**, when an output is available. Downloa are optional supporting outputs, not a prerequisite for reading or discussing the findings. -Downloads check current artifact and source access before returning the file. +Downloads check that you can still open this conversation and that the file is +intact before returning it. The documents the analysis read are not checked again. If a download fails, the conversation remains open and the download button becomes available again. Refresh the conversation before retrying a stale or unavailable file. A preview opening successfully does not establish that a download will work. @@ -137,24 +138,29 @@ instead of asking through the saved-analysis notice. ## Understand unavailable and stale results -Saved Analyze results produced by this feature require current access to every -contributing source for new reads or reuse. Keeping access to the conversation -alone is not sufficient. If access to any contributing source is lost, the entire -original saved result becomes unavailable, including its evidence, saved -explanations derived from it, and original exports. A mixed-source result is not -silently reduced to the sources you can still access. +Since **0.261.232**, a saved Analyze result takes its access from the conversation +it was saved in. Anyone who can open that conversation can read the result, its +evidence, explanations based on it, and its original exports. This holds even if a +document the analysis read is later deleted, replaced with a new upload, or held +for content screening. Nothing already produced is withdrawn. A newer version of a +document is reflected only when you run a new Analyze on it. + +The result becomes unavailable when you can no longer open its conversation, or +when its saved data fails an integrity check, for example because it no longer +matches the result your browser selected. **Already explicitly published workspace copies are different:** they remain separate documents governed by the destination workspace's permissions and -lifecycle. Losing access to an original contributing source does not replace -those destination permissions. Saving a result in its originating chat is not -workspace publication. Bytes already delivered or downloaded cannot be recalled. +lifecycle. Saving a result in its originating chat is not workspace publication. +Bytes already delivered or downloaded cannot be recalled. An unavailable or stale notice is not an empty successful analysis: -- **Saved analysis unavailable** can indicate removal or changed access. Result - reading, **Ask about this analysis**, and original result export controls are - unavailable. A sign-in notice asks you to sign in again before checking access. +- **Saved analysis unavailable** can indicate that the conversation or its saved + result was removed, that you lost access to the conversation, or that the saved + data failed an integrity check. Result reading, **Ask about this analysis**, and + original result export controls are unavailable. A sign-in notice asks you to + sign in again before checking access. - **Saved analysis is stale or has changed** asks you to reopen the conversation to use its current saved result, rather than silently switching results. - **Retry findings** or **Retry evidence** appears after a temporary loading diff --git a/docs/guides/review-screened-documents.md b/docs/guides/review-screened-documents.md index 4bf7ff572..38738d7fa 100644 --- a/docs/guides/review-screened-documents.md +++ b/docs/guides/review-screened-documents.md @@ -4,7 +4,7 @@ title: "Screen and review workspace documents" description: "Inspect sensitive or manipulative extracted content, keep it out of knowledge use, and release only a reviewed version." section: "Guides" audience: user -version: "0.261.230" +version: "0.261.232" --- ## What this does @@ -103,6 +103,16 @@ After remediation, the original stays restricted to reviewers; ordinary citation Review original files as potentially untrusted content. Do not follow links or instructions in the document simply because they appeared in a review view. +## What a hold does to earlier results + +Screening checks a document as it enters a workspace. Anything built from that document isn't screened or checked again, including AI replies, saved Analyze results, workflow and orchestration outputs, and generated files. So a hold stops the document from being used as an input from now on, but it doesn't take back anything already built from it. + +- Search, document selection, previews, downloads and opening the document from a citation are refused until review completes. +- If the document was attached in chat, the attachment shows "Source content is unavailable pending document screening and review." in the conversation. +- AI replies and their citation excerpts stay in the conversation and its exports. So do saved Analyze results, workflow and orchestration outputs, and generated files. Anyone who can open that conversation, workflow or run can still see them. + +Deleting, rejecting or screening a document again works the same way. To reflect a reviewed or replaced document, run a new search or Analyze against it. + ## Common problems | Symptom | Meaning and response | @@ -117,7 +127,7 @@ Review original files as potentially untrusted content. Do not follow links or i | Scanning was disabled but a document is still held | Disabling future scans does not approve existing held content. | | A file an agent saved, or an artifact you published, has no screening status | By design: documents SimpleChat generates aren't screened. | | A published artifact waiting for approval can't be approved because it's held by screening | It was requested before generated content stopped being screened. Cancel the request and publish the artifact again. | -| An older attachment or tool result is unavailable in conversation history | Its source is held, or that old result cannot prove it used the currently approved revision. Complete review and run a new query against the approved document. | +| A workspace attachment is unavailable in conversation history | Its document is held, deleted, or no longer available to you. AI replies and their citations stay visible. Complete review, then attach or search the approved document again. | | Another person cannot open the evidence | Review access is scoped. Administrative scan permission or ordinary shared-read access is not sufficient. | ## Related diff --git a/functional_tests/test_analysis_artifact_publication.py b/functional_tests/test_analysis_artifact_publication.py index 58e0bfcfe..991051877 100644 --- a/functional_tests/test_analysis_artifact_publication.py +++ b/functional_tests/test_analysis_artifact_publication.py @@ -1,11 +1,14 @@ # test_analysis_artifact_publication.py """ Functional tests for explicit existing-artifact publication and retry receipts. -Version: 0.261.137 +Version: 0.261.232 Implemented in: 0.261.109 +Saved-result access wording aligned in: 0.261.232 Exercise real publication, normalization, and route bodies with Cosmos/queue doubles. No Azure or model calls. XSD source files are scoped and cleaned up. +The saved-result reader is the access decision; it no longer rereads the +documents an analysis came from, so a lost result is modeled by the reader. """ import ast @@ -213,7 +216,7 @@ def read_saved_result(user_id, context): assert user_id == "actor" assert context["result_sha256"] == "saved-digest" if not state["source_allowed"]: - raise PermissionError("Contributing source access revoked") + raise PermissionError("Saved result access revoked") return {"execution": {"status": state["execution"]}, "validation": {"status": state["validation"]}}, None, {} def authorize_analysis(user_id, source, **kwargs): @@ -397,7 +400,7 @@ def test_unregistered_projections_and_staged_artifacts_are_blocked(publication): assert not publication.calls["create"] -def test_source_revocation_blocks_republication_not_existing_destination_copy(publication): +def test_lost_saved_result_access_blocks_republication_not_existing_destination_copy(publication): result = publish(publication) destination_id = result["document"]["id"] publication.state["source_allowed"] = False @@ -541,7 +544,7 @@ def revoke_during_download(*args): assert publication.messages.writes == 0 -def test_revocation_after_shell_creation_prevents_processing_and_approval(publication): +def test_result_access_loss_after_shell_creation_prevents_processing_and_approval(publication): publication.state["create_hook"] = lambda: publication.state.update(source_allowed=False) with pytest.raises(PermissionError): publish(publication, "group") diff --git a/functional_tests/test_analysis_saved_source_access.py b/functional_tests/test_analysis_saved_source_access.py index 19cb987e3..eb17c10fd 100644 --- a/functional_tests/test_analysis_saved_source_access.py +++ b/functional_tests/test_analysis_saved_source_access.py @@ -1,11 +1,14 @@ # test_analysis_saved_source_access.py """ -Functional tests for current source access on saved Analyze results. -Version: 0.261.107 +Functional tests for the current-source check used by in-progress Analyze reads. +Version: 0.261.232 Implemented in: 0.261.107 +Limited to input reads in: 0.261.232 -Saved results require access to every contributor; large selections are resolved -without dropping sources, and historical explanation differs from checkpoint reuse. +An Analyze run that is still reading documents (a resumed checkpoint or a native +computation) requires access to every input; large selections are resolved without +dropping sources, and a changed input cannot resume a checkpoint. Saved results do +not call this check: they take their access from their conversation or run. """ from copy import deepcopy @@ -54,7 +57,7 @@ def test_source_resolution_reads_every_source_in_bounded_batches(count): assert all(len(batch) <= access.SOURCE_MANIFEST_MAX_SOURCES for batch, _ in calls) -def test_one_revoked_contributor_blocks_the_entire_mixed_result(): +def test_one_revoked_input_blocks_resuming_a_mixed_analysis(): original = [ source("personal"), source("group", "group", "group-1"), @@ -76,7 +79,7 @@ def test_a_different_authorized_scope_cannot_substitute_for_the_original(field, @pytest.mark.parametrize("field", ["source_version", "source_revision"]) -def test_history_can_be_explained_but_changed_sources_cannot_resume(field): +def test_changed_input_is_reported_and_cannot_resume_a_checkpoint(field): original = [source()] fresh = deepcopy(original) fresh[0][field] = "changed" diff --git a/functional_tests/test_analyze_backend_saved_integration.py b/functional_tests/test_analyze_backend_saved_integration.py index dcd90700c..6c98281e4 100644 --- a/functional_tests/test_analyze_backend_saved_integration.py +++ b/functional_tests/test_analyze_backend_saved_integration.py @@ -1,12 +1,15 @@ # test_analyze_backend_saved_integration.py """ Behavioral integration tests for Analyze presentation and saved-data chat reuse. -Version: 0.261.122 +Version: 0.261.232 Implemented in: 0.261.109 +Container-only saved-result access covered in: 0.261.232 Real adapter, artifact, history, chat route and shared section-reader functions run against serialized storage, Flask requests and deterministic model doubles. No original document extraction, workspace upload or live Azure call is made. +Saved follow-ups and history take their access from the conversation, not from +the documents the analysis read. """ import ast @@ -568,6 +571,14 @@ def rollback(*args, **kwargs): "collect_conversation_metadata": lambda **kwargs: kwargs["conversation_item"], "_build_agent_selection_metadata": lambda *args: {}, "merge_cited_documents_into_conversation": lambda *args: None, + # Chat text checks are off in this fixture; their real behavior has its own tests. + "check_chat_content": lambda text, stage, **kwargs: SimpleNamespace(blocked=False), + "attach_chat_check": lambda document, result: None, + "checked_history_messages": lambda values, **kwargs: list(values or []), + "prepare_checked_reply": lambda message, **kwargs: message, + "persist_chat_reply": lambda container, message: container.upsert_item(message), + "reply_is_retracted": lambda message: False, + "record_chat_content_incident": lambda *args, **kwargs: None, }) names = { "SavedAnalysisFollowupUnsupported", "_invoke_saved_analysis_chat_reply", @@ -757,11 +768,19 @@ def test_saved_followup_blocks_full_input_that_exceeds_selected_model_budget(cha assert not [item for item in chat.messages.documents.values() if item["id"] != "assistant-1" and item["role"] == "assistant"] -def test_revocation_blocks_saved_followup_before_model_or_assistant_persistence(chat): +def test_source_revocation_does_not_block_saved_followup(chat): chat.source_state["source_allowed"] = False response = chat.client.post("/api/chat", json=followup_body(chat)) + assert response.status_code == 200, response.get_json() + assert response.get_json()["reply"].startswith("All 60 saved controls") + assert len(chat.state["model_calls"]) == 1 + + +def test_conversation_loss_blocks_saved_followup_before_model_or_assistant_persistence(chat): + chat.source_state["conversation_allowed"] = False + response = chat.client.post("/api/chat", json=followup_body(chat)) assert response.status_code == 403 - assert "source access" in response.get_json()["error"] + assert "source access" not in response.get_json()["error"] assert chat.state["model_calls"] == [] assert len(chat.messages.documents) == 1 @@ -901,7 +920,7 @@ def complete(**kwargs): assert len(calls) > 1 and chat.state["analyze_calls"] == [] -def test_history_propagates_identity_and_withholds_derived_text_after_revocation(chat): +def test_history_propagates_identity_and_keeps_derived_text_after_source_revocation(chat): origin = deepcopy(chat.messages.documents["assistant-1"]) with chat.app.test_request_context(): chat.namespace["build_conversation_history_segments"]([origin], 10) @@ -916,6 +935,11 @@ def test_history_propagates_identity_and_withholds_derived_text_after_revocation chat.source_state["source_allowed"] = False with chat.app.test_request_context(): segments = chat.namespace["build_conversation_history_segments"]([origin, derived], 10) + assert chat.namespace["_analysis_history_metadata"]() == lineage + assert "PRIVATE-COPIED-EXPLANATION" in json.dumps(segments, default=list) + chat.source_state["conversation_allowed"] = False + with chat.app.test_request_context(): + withheld = chat.namespace["build_conversation_history_segments"]([origin, derived], 10) assert chat.namespace["_analysis_history_metadata"]() == {} - assert "PRIVATE-COPIED" not in json.dumps(segments, default=list) - assert "unavailable" in json.dumps(segments, default=list).lower() + assert "PRIVATE-COPIED" not in json.dumps(withheld, default=list) + assert "unavailable" in json.dumps(withheld, default=list).lower() diff --git a/functional_tests/test_analyze_live_write_fences.py b/functional_tests/test_analyze_live_write_fences.py index a16ee64ce..4deef693c 100644 --- a/functional_tests/test_analyze_live_write_fences.py +++ b/functional_tests/test_analyze_live_write_fences.py @@ -66,7 +66,7 @@ def authorize(user_id, item): def factory(*args, **kwargs): return checkpoints_module.analysis_checkpoints_for_chat( *args, store=store, - source_authorizer=lambda user, sources, **options: saved.authorize_analysis_sources( + source_authorizer=lambda user, sources, **options: access.authorize_analysis_sources( user, sources, resolver=saved_chat["source_resolver"], **options, ), **kwargs, @@ -260,7 +260,7 @@ def test_live_runner_forwards_work_units_and_reuses_the_guarded_final_checkpoint store = storage.WorkflowResultStore(AnalysisMemoryContainer(), None, "private-results") def source_authorizer(user, sources, **kwargs): - return saved.authorize_analysis_sources( + return access.authorize_analysis_sources( user, sources, resolver=lambda ids, **context: deepcopy(manifest), **kwargs, ) @@ -360,7 +360,7 @@ def test_workflow_task_retry_reuses_its_real_checkpointed_producer(monkeypatch): def factory(*args, **kwargs): result = original_factory( *args, **kwargs, store=store, - source_authorizer=lambda user, sources, **options: saved.authorize_analysis_sources( + source_authorizer=lambda user, sources, **options: access.authorize_analysis_sources( user, sources, resolver=lambda ids, **context: deepcopy(manifest), **options, ), ) diff --git a/functional_tests/test_analyze_native_saved_integration.py b/functional_tests/test_analyze_native_saved_integration.py index 9b6565308..3fcc9c966 100644 --- a/functional_tests/test_analyze_native_saved_integration.py +++ b/functional_tests/test_analyze_native_saved_integration.py @@ -1,11 +1,14 @@ # test_analyze_native_saved_integration.py """ Native Analyze output, adapter and saved-consumer integration regressions. -Version: 0.261.127 +Version: 0.261.232 Implemented in: 0.261.109 +Container-only saved-result access covered in: 0.261.232 Real native checkpoint serialization, adaptation and saved-section readers run against offline run/blob/provider boundaries. No source is uploaded or re-indexed. +Adapting native output is an input read and still requires source access; a saved +result is then read with its conversation's access only. """ import asyncio @@ -192,8 +195,10 @@ def test_complete_native_checkpoints_survive_saving_beyond_the_old_eight_thousan assert [record["values"] for record in json.loads(payload)["records"]] == fixture.rows assert len(fixture.reads) == 3 fixture.state["allowed"] = False - with pytest.raises(PermissionError): - saved.read_saved_analysis_page(USER_ID, saved.saved_analysis_context(descriptor), **options) + # 0.261.232: the saved result takes its conversation's access; its source is provenance. + page = saved.read_saved_analysis_page(USER_ID, saved.saved_analysis_context(descriptor), **options) + assert page["total_records"] == len(fixture.rows) + assert [record["values"] for record in page["records"]] == fixture.rows[:len(page["records"])] assert len(fixture.reads) == 3 diff --git a/functional_tests/test_analyze_orchestration_saved_integration.py b/functional_tests/test_analyze_orchestration_saved_integration.py index 18cb86edb..1e82bde99 100644 --- a/functional_tests/test_analyze_orchestration_saved_integration.py +++ b/functional_tests/test_analyze_orchestration_saved_integration.py @@ -1,14 +1,16 @@ # test_analyze_orchestration_saved_integration.py """ Behavioral tests for saved Analyze references through orchestration. -Version: 0.261.139 +Version: 0.261.232 Implemented in: 0.261.109 Single orchestration contract updated in: 0.261.139 +Container-only retained-result access covered in: 0.261.232 Adapters, collection, section persistence/readers and checkpoint codecs are production functions. Only provider, storage and source-access boundaries are offline doubles; saved Analyze results are consumed through retained result -bindings instead of the removed legacy response path. +bindings instead of the removed legacy response path. A retained result takes +its access from its conversation and run, not from the documents it read. """ import hashlib @@ -260,23 +262,29 @@ def test_orchestration_keeps_full_data_in_saved_sections_not_evidence_or_checkpo assert len(fixture.state["producer_calls"]) == 1 -def test_orchestration_source_revocation_blocks_saved_compose(orchestration): +@pytest.mark.parametrize("change", ["revoked", "held"]) +def test_orchestration_saved_compose_keeps_run_access_after_source_changes(orchestration, change): + # 0.261.232: a retained Analyze result takes its access from its conversation and run. fixture = orchestration bind_result(fixture, analyze(fixture)) - fixture.state["allowed"] = False - with pytest.raises(Exception) as failure: - compose(fixture) - assert type(failure.value).__name__ in {"AnalysisResultUnavailable", "ResultUnavailableError"} - assert fixture.state["model_calls"] == [] + if change == "revoked": + fixture.state["allowed"] = False + else: + fixture.state["screening_held"] = True + answer = compose(fixture) + assert answer["status"] == "completed", answer + submitted = json.dumps(fixture.state["model_calls"][0][0]) + assert "finding-059" in submitted and "Complete finding 059" in submitted + assert len(fixture.state["producer_calls"]) == 1 -def test_orchestration_new_screening_hold_blocks_saved_compose(orchestration): +def test_orchestration_saved_compose_still_requires_its_conversation(orchestration): fixture = orchestration bind_result(fixture, analyze(fixture)) - fixture.state["screening_held"] = True + fixture.results.conversation["orchestration_deleted"] = True with pytest.raises(Exception) as failure: compose(fixture) - assert type(failure.value).__name__ in {"DocumentHeldError", "ResultUnavailableError"} + assert type(failure.value).__name__ in {"AnalysisResultUnavailable", "ResultUnavailableError"} assert fixture.state["model_calls"] == [] diff --git a/functional_tests/test_chat_artifact_download_bytes.py b/functional_tests/test_chat_artifact_download_bytes.py index ce90b8a4e..eaeccd9f2 100644 --- a/functional_tests/test_chat_artifact_download_bytes.py +++ b/functional_tests/test_chat_artifact_download_bytes.py @@ -1,13 +1,16 @@ # test_chat_artifact_download_bytes.py """ Functional regressions for authorized generated artifact download bytes. -Version: 0.261.127 +Version: 0.261.232 Implemented in: 0.261.115 +Container-only generated-file access covered in: 0.261.232 Production route, message/lifecycle authorization, internal blob reader, saved -analysis/source reader, and response functions execute against isolated storage. +analysis reader, and response functions execute against isolated storage. Workspace-document admission is deliberately unavailable: a standalone generated -artifact must not be misidentified as an ID-less workspace source. +artifact must not be misidentified as an ID-less workspace source. A generated +file takes its access from its conversation, so its source documents are never +reread; a workspace-linked artifact reads a workspace document and is checked. """ import ast @@ -136,6 +139,7 @@ def readall(): "assert_generated_file_approval_allows_download": approval, "assert_generated_chat_artifact_is_published_for_user": publication["assert_generated_chat_artifact_is_published_for_user"], "assert_evidence_available": screening_access.assert_evidence_available, + "assert_document_available": screening_access.assert_document_available, "assert_current_request_sources_available": screening_access.assert_current_request_sources_available, "read_available_document_bytes": screening_access.read_available_document_bytes, "get_current_user_id": lambda: identity["user_id"], @@ -181,17 +185,15 @@ def test_download_returns_exact_bytes_and_server_owned_filename(artifact_downloa assert response.headers["Content-Disposition"].startswith("attachment;") assert "no-store" in response.headers["Cache-Control"] assert response.headers["X-Content-Type-Options"] == "nosniff" - assert fixture.saved["state"]["resolutions"] >= 2 + assert fixture.saved["state"]["resolutions"] == 0 assert fixture.reads == [("personal-chat", "owner/conversation-1/generated/review.csv")] -@pytest.mark.parametrize("denial", ["conversation", "source", "approval", "staged", "deleted_parent"]) +@pytest.mark.parametrize("denial", ["conversation", "approval", "staged", "deleted_parent"]) def test_denial_happens_before_blob_read(artifact_download, denial): fixture = artifact_download if denial == "conversation": fixture.identity["allowed"] = False - elif denial == "source": - fixture.saved["state"]["source_allowed"] = False elif denial == "approval": fixture.identity["approved"] = False elif denial == "staged": @@ -204,14 +206,28 @@ def test_denial_happens_before_blob_read(artifact_download, denial): assert b"PRIVATE" not in response.data and CSV not in response.data -@pytest.mark.parametrize("change", ["source", "membership", "approval", "reference", "revision"]) +@pytest.mark.parametrize("during_read", [False, True]) +def test_source_revocation_does_not_withhold_the_generated_file(artifact_download, during_read): + fixture = artifact_download + + def revoke(): + fixture.saved["state"]["source_allowed"] = False + + if during_read: + fixture.state["after_read"] = revoke + else: + revoke() + response = fixture.client.get(DOWNLOAD) + assert response.status_code == 200 and response.data == CSV + assert fixture.saved["state"]["resolutions"] == 0 + + +@pytest.mark.parametrize("change", ["membership", "approval", "reference", "revision"]) def test_access_or_identity_changes_during_read_never_release_bytes(artifact_download, change): fixture = artifact_download def change_during_read(): - if change == "source": - fixture.saved["state"]["source_allowed"] = False - elif change == "membership": + if change == "membership": fixture.identity["allowed"] = False elif change == "approval": fixture.identity["approved"] = False @@ -292,25 +308,41 @@ def test_cleared_source_artifact_uses_its_bound_chat_bytes(screened_download): fixture, screening = screened_download response = fixture.client.get(DOWNLOAD) assert response.status_code == 200 and response.data == CSV - assert screening.personal.reads["document-1"] >= 2 + assert screening.personal.reads["document-1"] == 0 assert screening.blob_requests == [] @pytest.mark.parametrize("during_read", [False, True]) -def test_genuine_screening_hold_returns_explicit_409_without_artifact_bytes(screened_download, during_read): +def test_screening_hold_on_a_source_does_not_withhold_the_generated_file(screened_download, during_read): fixture, screening = screened_download if during_read: fixture.state["after_read"] = screening.hold else: screening.hold() response = fixture.client.get(DOWNLOAD) + assert response.status_code == 200 and response.data == CSV + assert response.headers["Content-Disposition"].startswith("attachment;") + assert screening.personal.reads["document-1"] == 0 + assert len(fixture.reads) == 1 + + +@pytest.mark.parametrize("during_read", [False, True]) +def test_held_workspace_linked_artifact_returns_explicit_409(screened_download, during_read): + fixture, screening = screened_download + fixture.artifact["workspace_document_id"] = "document-1" + fixture.artifact["metadata"] = {"is_generated_chat_artifact": True} + if during_read: + screening.after_blob_read = screening.hold + else: + screening.hold() + response = fixture.client.get(DOWNLOAD) assert response.status_code == 409 assert response.json == { "error": DocumentHeldError.public_message, "error_code": "document_under_review", } assert "attachment" not in response.headers.get("Content-Disposition", "") assert "no-store" in response.headers["Cache-Control"] - assert len(fixture.reads) == int(during_read) + assert fixture.reads == [] def test_workspace_linked_artifact_reads_the_admitted_copy_not_the_retained_original(screened_download): diff --git a/functional_tests/test_content_screening_history.py b/functional_tests/test_content_screening_history.py index e16c008f0..a8ad2a99b 100644 --- a/functional_tests/test_content_screening_history.py +++ b/functional_tests/test_content_screening_history.py @@ -1,13 +1,16 @@ # test_content_screening_history.py """ Functional regressions for public history and native evidence quarantine. -Version: 0.261.230 +Version: 0.261.232 Implemented in: 0.261.106 +Upload-only history checks covered in: 0.261.232 Executes the real history route, artifact hydration, and model-history guard against fake Cosmos. Removed metadata aliases and missing release proofs must not turn old source material into newly authorized evidence. Since 0.261.230, -metadata edited after a release keeps the document available. +metadata edited after a release keeps the document available. Stored replies and +their citations are conversation content and are not rechecked; a workspace +attachment shown in history is an input read and is. """ import ast @@ -139,26 +142,31 @@ def test_source_authorization_descriptor_cannot_bypass_a_hold(self): with self.assertRaises(DocumentHeldError): access.assert_evidence_available(source, "user-1", cached=True) - def test_real_model_history_helper_does_not_replay_held_native_values(self): + def test_real_model_history_helper_replays_a_stored_reply_without_rechecking_its_sources(self): + reads = [] helper = load_body("route_backend_chats.py", "build_assistant_history_content_with_citations", { - "assert_evidence_available": access.assert_evidence_available, + "assert_evidence_available": lambda *args, **kwargs: reads.append(args), "_build_agent_citation_history_lines": lambda citations: [json.dumps(citations)], "_build_document_citation_history_lines": lambda citations: [], "_build_web_citation_history_lines": lambda citations: [], + "_truncate_history_citation_text": lambda text, max_chars: text, }) self.hold() for tabular in (False, True): with self.subTest(tabular=tabular): - with self.assertRaises(DocumentHeldError): - helper({"agent_citations": [self.native_citation(tabular=tabular)]}, "Previous answer") + content = helper({"agent_citations": [self.native_citation(tabular=tabular)]}, "Previous answer") + self.assertIn("Previous answer", content) + self.assertIn("TOOL_PRIVATE_CANARY", content) + self.assertEqual(reads, []) + self.assertEqual(self.personal.reads["document-1"], 0) - def test_history_api_suppresses_held_attachments_and_hydrated_tool_evidence(self): + def test_history_api_suppresses_held_attachments_but_keeps_replies_and_citations(self): citation = self.native_citation() items = [ {"id": "user-message", "role": "user", "content": "Preserve ordinary conversation text"}, {"id": "file-message", "role": "file", "workspace_document_id": "document-1", "file_content": "ATTACHMENT_PRIVATE_CANARY", "extracted_text": "ATTACHMENT_PRIVATE_CANARY"}, - {"id": "assistant-message", "role": "assistant", "content": "SOURCE_DERIVED_CANARY", + {"id": "assistant-message", "role": "assistant", "content": "SOURCE_DERIVED_REPLY", "agent_citations": [{"artifact_id": "artifact-one"}]}, ] namespace = { @@ -190,11 +198,13 @@ def test_history_api_suppresses_held_attachments_and_hydrated_tool_evidence(self self.assertEqual(response.status_code, 200) payload = response.get_json() self.assertIn("Preserve ordinary conversation text", json.dumps(payload)) - self.assertNotIn("PRIVATE_CANARY", json.dumps(payload)) - self.assertNotIn("SOURCE_DERIVED_CANARY", json.dumps(payload)) + self.assertNotIn("ATTACHMENT_PRIVATE_CANARY", json.dumps(payload)) messages_by_id = {message["id"]: message for message in payload["messages"]} self.assertTrue(messages_by_id["file-message"]["content_unavailable"]) - self.assertTrue(messages_by_id["assistant-message"]["content_unavailable"]) + reply = messages_by_id["assistant-message"] + self.assertEqual(reply["content"], "SOURCE_DERIVED_REPLY") + self.assertNotIn("content_unavailable", reply) + self.assertIn("TOOL_PRIVATE_CANARY", json.dumps(reply["agent_citations"])) self.assertGreater(self.personal.reads["document-1"], 0) def test_screened_attachment_history_uses_only_current_clean_text(self): diff --git a/functional_tests/test_content_screening_read_boundaries.py b/functional_tests/test_content_screening_read_boundaries.py index 9548cde4e..c8fd847b7 100644 --- a/functional_tests/test_content_screening_read_boundaries.py +++ b/functional_tests/test_content_screening_read_boundaries.py @@ -1,11 +1,14 @@ # test_content_screening_read_boundaries.py """ Behavioral tests for search, native caches, and resumed source consumption. -Version: 0.261.122 +Version: 0.261.232 Implemented in: 0.261.106 +Upload-only export checks covered in: 0.261.232 Executes the actual boundary function bodies with isolated fake dependencies. Azure Search, Cosmos, Blob Storage, and model providers are never contacted. +A running export still checks the source file it reads; a finished export and +the request provenance stored with it are not rechecked. """ import ast @@ -287,12 +290,7 @@ def test_remembered_native_location_cannot_fall_back_after_hold(self): class ResumedEvidenceBoundaryTests(ScreeningAccessFixture): - def test_cached_native_status_does_not_republish_or_expose_held_previews(self): - run = { - "id": "run-1", "user_id": "user-1", "conversation_id": "conversation-1", - "status": "completed", "generated_artifact": {"preview_text": "PRIVATE PREVIEW"}, - "screening_sources": [{access.PROVENANCE_FIELD: access.document_provenance(self.document)}], - } + def native_status_namespace(self, run): reconciled = [] namespace = { "cosmos_conversations_container": self.conversations, @@ -300,22 +298,61 @@ def test_cached_native_status_does_not_republish_or_expose_held_previews(self): "get_settings": lambda: {}, "CosmosResourceNotFoundError": KeyError, "ScreeningError": ScreeningError, + "TABULAR_EXPORT_STATUS_COMPLETED": "completed", "assert_evidence_available": access.assert_evidence_available, "assert_blob_available": access.assert_blob_available, + "storage_account_personal_chat_container_name": "personal-chat", + "storage_account_user_documents_container_name": "user-documents", + "storage_account_group_documents_container_name": "group-documents", + "storage_account_public_documents_container_name": "public-documents", "_can_cancel_run": lambda _run: False, - "_reconcile_completed_tabular_artifact_set": lambda _run: reconciled.append(True), + "_reconcile_completed_tabular_artifact_set": lambda _run: reconciled.append(True) or _run, + "_build_run_public_status": lambda _run, settings=None: {"id": _run["id"], "source_available": True}, } load_functions( "functions_tabular_generated_exports.py", - {"_authorize_tabular_export_run_execution", "_screened_run_public_status", "get_tabular_generated_output_run_status"}, + { + "_authorize_tabular_export_run_conversation", "_authorize_tabular_export_run_execution", + "_screened_run_public_status", "get_tabular_generated_output_run_status", + }, namespace, ) + return namespace, reconciled + + def test_completed_export_status_follows_its_conversation_not_its_held_source(self): + run = { + "id": "run-1", "user_id": "user-1", "conversation_id": "conversation-1", + "status": "completed", "generated_artifact": {"preview_text": "GENERATED PREVIEW"}, + "screening_sources": [{access.PROVENANCE_FIELD: access.document_provenance(self.document)}], + "source_authorization": { + "source": "workspace", "scope_id": "user-1", "container": "user-documents", + "blob_path": self.document["content_screening"]["active_blob"]["path"], + }, + } + namespace, reconciled = self.native_status_namespace(run) + self.hold() + status = namespace["get_tabular_generated_output_run_status"]("user-1", "run-1") + self.assertTrue(status["source_available"]) + self.assertEqual(reconciled, [True]) + self.assertEqual(self.personal.reads["document-1"], 0) + self.conversations.documents["conversation-1"]["user_id"] = "someone-else" + denied = namespace["get_tabular_generated_output_run_status"]("user-1", "run-1") + self.assertFalse(denied["source_available"]) + self.assertEqual(denied["generated_artifacts"], []) + + def test_running_export_status_still_stops_when_its_source_file_is_held(self): + run = { + "id": "run-1", "user_id": "user-1", "conversation_id": "conversation-1", "status": "running", + "source_authorization": { + "source": "workspace", "scope_id": "user-1", "container": "user-documents", + "blob_path": self.document["content_screening"]["active_blob"]["path"], + }, + } + namespace, reconciled = self.native_status_namespace(run) self.hold() status = namespace["get_tabular_generated_output_run_status"]("user-1", "run-1") self.assertFalse(status["source_available"]) - self.assertEqual(status["generated_artifacts"], []) self.assertFalse(status["can_resume"]) - self.assertNotIn("PRIVATE", json.dumps(status)) self.assertEqual(reconciled, []) def test_linked_history_uses_clean_text_instead_of_original_attachment_metadata(self): @@ -341,7 +378,7 @@ def test_linked_history_uses_clean_text_instead_of_original_attachment_metadata( self.assertEqual(result["role"], "file") self.assertIn(access.PROVENANCE_FIELD, result) - def test_persisted_native_run_rechecks_sources_before_reusing_staged_rows(self): + def test_persisted_native_run_rechecks_its_source_file_but_not_request_provenance(self): namespace = { "cosmos_conversations_container": self.conversations, "CosmosResourceNotFoundError": KeyError, @@ -352,14 +389,26 @@ def test_persisted_native_run_rechecks_sources_before_reusing_staged_rows(self): "storage_account_group_documents_container_name": "group-documents", "storage_account_public_documents_container_name": "public-documents", } - load_functions("functions_tabular_generated_exports.py", {"_authorize_tabular_export_run_execution"}, namespace) - run = { + load_functions( + "functions_tabular_generated_exports.py", + {"_authorize_tabular_export_run_conversation", "_authorize_tabular_export_run_execution"}, + namespace, + ) + provenance_only = { "user_id": "user-1", "conversation_id": "conversation-1", "screening_sources": [{access.PROVENANCE_FIELD: access.document_provenance(self.document)}], } + reading_source = { + **provenance_only, + "source_authorization": { + "source": "workspace", "scope_id": "user-1", "container": "user-documents", + "blob_path": self.document["content_screening"]["active_blob"]["path"], + }, + } self.hold() + namespace["_authorize_tabular_export_run_execution"](provenance_only) with self.assertRaises(DocumentHeldError): - namespace["_authorize_tabular_export_run_execution"](run) + namespace["_authorize_tabular_export_run_execution"](reading_source) def test_nested_historical_tool_provenance_is_not_lost_in_json(self): evidence = { diff --git a/functional_tests/test_orchestration_dependency_citations.py b/functional_tests/test_orchestration_dependency_citations.py index 3c85077dd..8feab3834 100644 --- a/functional_tests/test_orchestration_dependency_citations.py +++ b/functional_tests/test_orchestration_dependency_citations.py @@ -1,8 +1,9 @@ # test_orchestration_dependency_citations.py """Functional tests for authorized citations from the selected retained answer. -Version: 0.261.127 +Version: 0.261.232 Implemented in: 0.261.127 +Container-only retained citations covered in: 0.261.232 Real compiler, adapters, result storage, lineage checks, executor and recovery are used. Only search/model/network/storage transport is isolated. """ @@ -121,9 +122,11 @@ def test_reopened_exact_result_retains_citations_without_context_notes_or_genera @pytest.mark.parametrize('change', ['revoked', 'held', 'revision']) -def test_citations_reauthorize_current_sources_after_a_restart(grounded, change): +def test_citations_keep_the_answer_access_after_a_restart(grounded, change): + # 0.261.232: citations are provenance of the retained answer and take its conversation's + # and run's access. Opening a cited document is a separate input read. case = grounded.make() - execute(grounded.runtime, case) + result = execute(grounded.runtime, case) reference = case.context.task_results['answer'].output('answer') if change == 'revoked': case.fixture.denied.add('document-1') @@ -131,10 +134,15 @@ def test_citations_reauthorize_current_sources_after_a_restart(grounded, change) case.fixture.held.add('document-1') else: case.fixture.sources['document-1']['source_revision'] = 'changed-since-generation' + reads = len(case.fixture.source_reads) fresh = SimpleNamespace(result_service=case.fixture.restart()) - expected = grounded.runtime.composition.ScreeningError if change == 'held' else PermissionError - with pytest.raises(expected): - grounded.runtime.result_runtime.read_result_document_citations(fresh, reference) + citations = grounded.runtime.result_runtime.read_result_document_citations(fresh, reference) + assert citations == result['citations'] and citations + assert len(case.fixture.source_reads) == reads + case.fixture.conversation['orchestration_deleted'] = True + lost = SimpleNamespace(result_service=case.fixture.restart()) + with pytest.raises(PermissionError): + grounded.runtime.result_runtime.read_result_document_citations(lost, reference) assert len(case.model.calls) == 1 diff --git a/functional_tests/test_orchestration_dependency_commit_recovery.py b/functional_tests/test_orchestration_dependency_commit_recovery.py index 4a070db69..98bffcb6a 100644 --- a/functional_tests/test_orchestration_dependency_commit_recovery.py +++ b/functional_tests/test_orchestration_dependency_commit_recovery.py @@ -1,9 +1,10 @@ # test_orchestration_dependency_commit_recovery.py """Declared-input receipts close the v2 producer-commit/checkpoint crash window. -Version: 0.261.139 +Version: 0.261.232 Implemented in: 0.261.127 Single orchestration contract updated in: 0.261.139 +Container-only receipt recovery covered in: 0.261.232 Uses real facade, storage, checkpoint and recovery APIs with isolated external I/O. """ @@ -161,8 +162,8 @@ def test_receipt_recovery_rejects_corruption_or_changed_identity_without_replay( @pytest.mark.parametrize('status', ['complete', 'partial']) -@pytest.mark.parametrize('access_change', [None, 'revoked', 'held', 'revision']) -def test_partial_and_grounded_receipts_keep_completeness_and_current_access(durable, status, access_change): +@pytest.mark.parametrize('access_change', [None, 'revoked', 'held', 'revision', 'conversation']) +def test_partial_and_grounded_receipts_keep_completeness_and_container_access(durable, status, access_change): runtime = durable.runtime def producer(step, context, **kwargs): @@ -193,11 +194,15 @@ def producer(step, context, **kwargs): durable.case.fixture.held.add('document-1') elif access_change == 'revision': durable.case.fixture.sources['document-1']['source_revision'] = 'changed' - if access_change is not None: + elif access_change == 'conversation': + durable.case.fixture.conversation['orchestration_deleted'] = True + if access_change == 'conversation': with pytest.raises(runtime.checkpoints.CheckpointError) as failure: durable.recovery.validate_resume(record, durable.fresh_context(record), durable.case.settings, lambda: True) assert failure.value.code == 'result_unavailable' else: + # 0.261.232: a committed receipt takes its conversation's and run's access. The + # documents it was produced from are provenance and are not rechecked on recovery. payloads = durable.recovery.validate_resume( record, durable.fresh_context(record), durable.case.settings, lambda: True, ) diff --git a/functional_tests/test_orchestration_dependency_runtime.py b/functional_tests/test_orchestration_dependency_runtime.py index 9a7cfd33f..2f3ca605f 100644 --- a/functional_tests/test_orchestration_dependency_runtime.py +++ b/functional_tests/test_orchestration_dependency_runtime.py @@ -1,12 +1,13 @@ # test_orchestration_dependency_runtime.py """Real Gather / Reason / Render compiler, executor, composition, retained readers and checkpoints. -Version: 0.261.209 +Version: 0.261.232 Implemented in: 0.261.127 Pending-Gather regression implemented in: 0.261.129 Single orchestration contract updated in: 0.261.139 Partial-input refusal diagnostics implemented in: 0.261.191 Missing signed-in session failure messages implemented in: 0.261.209 +Container-only retained-result reads covered in: 0.261.232 External model/search/storage I/O is isolated; no paid or provider calls. """ @@ -769,7 +770,7 @@ def test_checkpoint_contains_refs_not_datasets_and_wait_has_separate_manifest(ru assert completed_payload == payload -@pytest.mark.parametrize('change', ['revocation', 'screening', 'revision', 'snapshot_revision', 'deletion', 'cancel']) +@pytest.mark.parametrize('change', ['deletion', 'cancel']) def test_current_access_and_cancellation_fail_closed(runtime, change): case = runtime.make([compose('producer'), compose('answer', inputs={'source': source_input('producer', 'answer')})], ['Should not be called'], final_response=binding('answer')) @@ -784,20 +785,13 @@ def produce(step, context, **kwargs): outputs=[runtime.NamedOutput('answer', 'markdown-v1', 'Source-bound answer.', runtime.complete(1))], sources=[case.fixture.sources['document-1']], origin='grounded', guard_token=context.result_guard_token_for_step(step['step_id']), - source_policy='snapshot' if change == 'snapshot_revision' else 'current', ) return runtime.schema.build_step_result(task_result=task) def event(value): if value.get('step_id') != 'producer' or value.get('phase') != 'completed': return - if change == 'revocation': - case.fixture.denied.add('document-1') - elif change == 'screening': - case.fixture.held.add('document-1') - elif change in ('revision', 'snapshot_revision'): - case.fixture.sources['document-1']['source_revision'] = 'changed' - elif change == 'deletion': + if change == 'deletion': case.fixture.conversation['orchestration_deleted'] = True else: case.fixture.runs['run-1']['cancellation_requested_at'] = 'server-stop' @@ -815,6 +809,45 @@ def event(value): assert 'reference' not in result['result_outputs'][0] +@pytest.mark.parametrize('change', ['revocation', 'screening', 'revision', 'snapshot_revision']) +def test_source_changes_after_a_producer_do_not_block_its_retained_result(runtime, change): + # 0.261.232: a later step reads the producer's retained result, which takes its access + # from the conversation and run. The documents behind it are provenance, not inputs. + case = runtime.make([compose('producer'), compose('answer', inputs={'source': source_input('producer', 'answer')})], + ['Answer from the retained result.'], final_response=binding('answer')) + called = [] + + def produce(step, context, **kwargs): + called.append(step['step_id']) + if step['step_id'] == 'answer': + return runtime.composition.adapter_compose(step, context, **kwargs) + task = context.result_service.persist_task_result( + producer=context.result_producer(step), role='reason', status='complete', + outputs=[runtime.NamedOutput('answer', 'markdown-v1', 'Source-bound answer.', runtime.complete(1))], + sources=[case.fixture.sources['document-1']], origin='grounded', + guard_token=context.result_guard_token_for_step(step['step_id']), + source_policy='snapshot' if change == 'snapshot_revision' else 'current', + ) + return runtime.schema.build_step_result(task_result=task) + + def event(value): + if value.get('step_id') != 'producer' or value.get('phase') != 'completed': + return + if change == 'revocation': + case.fixture.denied.add('document-1') + elif change == 'screening': + case.fixture.held.add('document-1') + else: + case.fixture.sources['document-1']['source_revision'] = 'changed' + + result = execute(runtime, case, get_adapter=lambda capability: produce, emit=event) + assert result['status'] == 'completed', result + assert called == ['producer', 'answer'] + assert result['message'] == 'Answer from the retained result.' + assert len(case.model.calls) == 1 + assert 'Source-bound answer.' in json.dumps(case.model.calls[0][0]) + + def test_default_registry_and_plan_admission_use_the_single_contract(runtime): ids = runtime.registry.all_capability_ids() assert {'compose', 'render_file'} <= set(ids) and 'respond' not in ids diff --git a/functional_tests/test_orchestration_export_sources.py b/functional_tests/test_orchestration_export_sources.py index de8f48627..a54d4f319 100644 --- a/functional_tests/test_orchestration_export_sources.py +++ b/functional_tests/test_orchestration_export_sources.py @@ -1,12 +1,14 @@ # test_orchestration_export_sources.py """ Authorized retained-result to shared-export integration. -Version: 0.261.126 +Version: 0.261.232 Implemented in: 0.261.126 +Container-only retained-result access covered in: 0.261.232 Uses real result persistence/readers and serializers with external storage and source access doubled. Verifies full consumption, exact counts, explicit public -projections, revocation, and no replacement of authoritative data with previews. +projections, producer revocation, and no replacement of authoritative data with +previews. Source documents of a retained result are never reread for an export. """ import csv @@ -20,7 +22,6 @@ import pytest -from functions_analysis_access import AnalysisResultUnavailable from functions_generated_file_exports import GeneratedFileExportRequest, build_generated_file_export from functions_generated_export_contracts import GeneratedFileExportError from functions_orchestration_export_sources import ( @@ -214,13 +215,27 @@ def test_a_partial_iteration_is_not_an_integrity_receipt(): assert complete_records == ROWS -def test_access_revocation_prevents_render_and_invalidates_an_existing_receipt(): +def test_source_revocation_keeps_the_receipt_and_renders(): fixture = ResultFixture() saved = fixture.save() bound = open_orchestration_export_source(fixture.service, saved.output("findings")) records = list(bound.iter_records()) bound.require_complete_consumption() fixture.denied.add("document-1") + fixture.held.add("document-1") + bound.require_complete_consumption() + render(bound) + assert records == ROWS + assert fixture.source_reads == [] + + +def test_producer_revocation_prevents_render_and_invalidates_an_existing_receipt(): + fixture = ResultFixture() + saved = fixture.save() + bound = open_orchestration_export_source(fixture.service, saved.output("findings")) + records = list(bound.iter_records()) + bound.require_complete_consumption() + fixture.runs["run-1"]["checkpoints_deleted"] = True with pytest.raises(PermissionError): bound.require_complete_consumption() with pytest.raises(PermissionError): @@ -228,13 +243,13 @@ def test_access_revocation_prevents_render_and_invalidates_an_existing_receipt() assert records == ROWS -def test_revocation_during_iteration_is_detected_at_exhaustion(): +def test_producer_revocation_during_iteration_is_detected_at_exhaustion(): fixture = ResultFixture() saved = fixture.save() bound = open_orchestration_export_source(fixture.service, saved.output("findings")) records = bound.iter_records() first = next(records) - fixture.denied.add("document-1") + fixture.conversation["orchestration_deleted"] = True with pytest.raises(PermissionError): list(records) assert first == ROWS[0] @@ -259,7 +274,7 @@ def test_rebinding_reader_metadata_fails_instead_of_changing_the_export(field): assert bound.integrity_verified is False -def test_current_source_requirement_does_not_silently_replace_a_saved_snapshot(): +def test_current_source_requirement_reads_the_saved_snapshot_after_a_source_change(): fixture = ResultFixture() saved = fixture.save(source_policy="snapshot") fixture.sources["document-1"]["source_revision"] = "revision-2" @@ -267,10 +282,11 @@ def test_current_source_requirement_does_not_silently_replace_a_saved_snapshot() records = list(bound.iter_records()) bound.require_complete_consumption() assert records == ROWS - with pytest.raises(AnalysisResultUnavailable): - open_orchestration_export_source( - fixture.service, saved.output("findings"), require_current_sources=True, - ) + current = open_orchestration_export_source( + fixture.service, saved.output("findings"), require_current_sources=True, + ) + assert list(current.iter_records()) == ROWS + assert fixture.source_reads == [] @pytest.mark.parametrize("extra", [False, True]) diff --git a/functional_tests/test_orchestration_file_outcome_projection.py b/functional_tests/test_orchestration_file_outcome_projection.py index c9ad14c68..f09e1ae3c 100644 --- a/functional_tests/test_orchestration_file_outcome_projection.py +++ b/functional_tests/test_orchestration_file_outcome_projection.py @@ -1,8 +1,9 @@ # test_orchestration_file_outcome_projection.py """Runtime file outcomes come only from current authorized rendering-service projections. -Version: 0.261.127 +Version: 0.261.232 Implemented in: 0.261.127 +Container-only file access covered in: 0.261.232 Real renderer, result store, artifact transport and commit readers use isolated external I/O. """ @@ -49,7 +50,12 @@ def test_denied_file_stays_visible_without_hiding_its_authorized_sibling(lifecyc allowed = lifecycle.prepare('json', step_id='allowed_file', reference=allowed_source) lifecycle.run(restricted) lifecycle.run(allowed) - lifecycle.results.held.add('restricted') + # 0.261.232: holding a document behind a retained result does not hide its file; + # losing the retained result's own run does. + lifecycle.results.held.add('allowed') + run = lifecycle.runs.read_item(restricted_source.producer.run_id, 'conversation-1') + run['checkpoints_deleted'] = True + lifecycle.runs.upsert_item(run) before = list(lifecycle.render_calls) outputs, artifacts = _dependency_file_outcomes(context_for(lifecycle), settings={}, user_id='owner') diff --git a/functional_tests/test_orchestration_harness_execution.py b/functional_tests/test_orchestration_harness_execution.py index 0194c9a26..1de5b4ccc 100644 --- a/functional_tests/test_orchestration_harness_execution.py +++ b/functional_tests/test_orchestration_harness_execution.py @@ -1,11 +1,12 @@ # test_orchestration_harness_execution.py """Real-boundary headless V2 preparation, execution and publication regressions. -Version: 0.261.139 +Version: 0.261.232 Implemented in: 0.261.127 Invocation, catalog, and citation regressions implemented in: 0.261.129 Direct initial-binding scope coverage implemented in: 0.261.130 Single orchestration contract updated in: 0.261.139 +Retained answers and citations take their run's access in: 0.261.232 Production context, models, runtime, services, result store, checkpoint/lease and artifact adapters execute with external Azure/model I/O doubled. Cold imports run @@ -3437,8 +3438,15 @@ def recheck_source(context, reference): record, services=services, settings=harness.settings, lease=execution.lease, ) done = decoded_frames(frames)[-1] - assert done["status"] == "failed" and done["failure"]["code"] == "context_unavailable" - assert done["hybrid_citations"] == [] and "Prepared from exactly" not in done["full_content"] + if change == "revision": + # 0.261.232: the retained answer and its citations are generated content. A newer + # version of a document the run already read neither withdraws nor blocks them. + assert done["status"] == "completed", done + assert done["hybrid_citations"] and "Prepared from exactly" in done["full_content"] + else: + # Delivery still confirms that the documents this run read are accessible and not held. + assert done["status"] == "failed" and done["failure"]["code"] == "context_unavailable" + assert done["hybrid_citations"] == [] and "Prepared from exactly" not in done["full_content"] execution.close() saved = harness.read() messages = harness.assistant_messages() @@ -3446,9 +3454,13 @@ def recheck_source(context, reference): assert references == [record["final_response"]] if change == "storage": assert messages == [] and saved["status"] == record["status"] and not saved.get("failure") + elif change == "revision": + assert len(messages) == 1 and messages[0]["hybrid_citations"] and saved["citations"] else: assert len(messages) == 1 and messages[0]["hybrid_citations"] == [] and saved["citations"] == [] - assert not conversation.get("used_documents") and saved["task_results"] == record["task_results"] + if change != "revision": + assert not conversation.get("used_documents") + assert saved["task_results"] == record["task_results"] assert harness.results.container.items == rows_before and harness.steps.items == steps_before assert len(citation_execution.queries) == 2 and len(harness.model_calls) == 1 assert harness.blobs.file_uploads == 0 and saved["execution_lease"] is None diff --git a/functional_tests/test_orchestration_internal_analysis.py b/functional_tests/test_orchestration_internal_analysis.py index bc11d450c..e75abac66 100644 --- a/functional_tests/test_orchestration_internal_analysis.py +++ b/functional_tests/test_orchestration_internal_analysis.py @@ -1,9 +1,10 @@ # test_orchestration_internal_analysis.py """ Real v2 adapters -> native Analyze/Compare -> saved readers -> generic result store. -Version: 0.261.139 +Version: 0.261.232 Implemented in: 0.261.127 Single orchestration contract updated in: 0.261.139 +Retained results take container access only in: 0.261.232 Source/model/Cosmos/Blob I/O is offline. Producers, native work-unit checkpoints, saved Analyze contracts, complete readers and the M1 facade/store are real. @@ -27,7 +28,6 @@ # Application imports follow the standalone test path setup. from content_screening import access as screening_access -from content_screening.contracts import ScreeningError from functions_analysis_access import analysis_source_snapshot, authorize_analysis_sources from functions_document_analysis_checkpoints import AnalysisWorkUnitCheckpoints from functions_orchestration_result_contracts import RecordColumn, ResultContractError, TaskResult @@ -347,15 +347,22 @@ def test_saved_record_source_rejects_an_incompatible_declared_projection(interna @pytest.mark.parametrize('change', ['revoked', 'screened', 'revision']) -def test_completed_native_and_generic_data_recheck_sources_after_restart(internal, change): +def test_completed_native_and_generic_data_keep_container_access_after_restart(internal, change): + # 0.261.232: retained results take access from their conversation and run, not their sources. result = analyze(internal) + expected = list(internal.fixture.service.open_result(result['task_result'].output('findings')).iter_records()) if change == 'revoked': internal.fixture.denied.add('document-1') elif change == 'screened': internal.fixture.held.add('document-1') else: internal.fixture.sources['document-1']['source_revision'] = 'new-revision' - with pytest.raises((PermissionError, ScreeningError)): + reads = len(internal.fixture.source_reads) + reader = internal.fixture.restart().open_result(result['task_result'].output('findings')) + assert list(reader.iter_records()) == expected + assert len(internal.fixture.source_reads) == reads + internal.fixture.conversation['user_id'] = 'another-user' + with pytest.raises(PermissionError): internal.fixture.restart().open_result(result['task_result'].output('findings')) assert internal.hooks.call_count == 0 @@ -879,3 +886,82 @@ def commit_then_disconnect(*args, **kwargs): producer=task.producer, input_fingerprint=adapted_inputs[0]['adapted_fingerprint'], ) assert wrong_receipt is None + +def _run_analyze_on_sources_named_by_an_earlier_run(internal, sources): + runtime = importlib.import_module('functions_orchestration_executor') + schema = importlib.import_module('functions_orchestration_schema') + settings = {'enable_user_workspace': True} + internal.state['mode'] = 'analyze' + prior = replace( + internal.fixture.producer, run_id='prior-run', step_id='gather', + capability_id='document_search', contract_version='orchestration-gathered-content-v1', + ) + internal.fixture.add_producer(prior) + checkpoint = AnalysisWorkUnitCheckpoints( + internal.fixture.service.store, + _orchestration_identity(prior.user_id, prior.conversation_id, prior.run_id, prior.step_id), + user_id=prior.user_id, + authorize=lambda: internal.fixture.service.access.authorize_producer(prior, for_write=True), + ) + checkpoint.prepare() + gathered = internal.fixture.service.persist_task_result( + producer=prior, role='gather', status='complete', sources=sources, + origin='grounded', guard_token=checkpoint.token, + outputs=[NamedOutput('sources', 'source-set-v1', sources, complete(len(sources)))], + ) + aliases = {'selected_sources': gathered.output('sources')} + plan = schema.normalize_plan( + {'run_id': 'run-1', 'steps': [{ + 'step_id': 'analyze', 'capability_id': 'document_analyze', + 'arguments': {'analysis_prompt': 'Retain all original findings.'}, + 'inputs': {'sources': { + 'binding': { + 'version': 'orchestration-input-binding-v1', 'step_id': None, + 'output_name': None, 'existing_result': 'selected_sources', + }, + 'allow_partial': False, + }}, + }]}, + 'conversation-1', 'owner', settings=settings, contract_version=2, + available_capability_ids=['document_analyze'], existing_results=aliases, + ) + internal.fixture.runs['run-1']['plan'] = plan + context = runtime.RunContext( + run_id='run-1', plan_id=plan['plan_id'], conversation_id='conversation-1', user_id='owner', + user_message='Retain all requested findings.', plan_contract_version=2, + result_service=internal.fixture.service, resolve_source_manifest=internal.fixture.resolve, + result_aliases=aliases, invoke_prompt=internal.context.invoke_prompt, + result_guard_token_for_step=internal.context.result_guard_token_for_step, + ) + context.analysis_checkpoint_factory = internal.context.analysis_checkpoint_factory + return runtime.execute_plan(plan, context, settings=settings, user_id='owner', emit=[].append) + + +def test_follow_up_reads_sources_named_by_an_earlier_run_at_their_current_version(internal): + # 0.261.232: an earlier run's source list only selects documents; a newer version is not an error. + earlier = analysis_source_snapshot([{ + **internal.fixture.sources['document-1'], 'source_version': 0, 'source_revision': 'revision-0', + }]) + result = _run_analyze_on_sources_named_by_an_earlier_run(internal, earlier) + assert result['status'] == 'completed', result + task = TaskResult.from_dict(result['task_results']['analyze']) + coverage = internal.fixture.service.open_result(task.output('coverage')).read_value() + assert [(source['document_id'], source['source_version']) for source in coverage['sources']] == [ + ('document-1', 1), + ] + assert internal.state['calls'] + assert internal.hooks.call_count == 0 + + +@pytest.mark.parametrize('change', ['revoked', 'screened']) +def test_follow_up_still_checks_sources_named_by_an_earlier_run_as_inputs(internal, change): + earlier = analysis_source_snapshot([internal.fixture.sources['document-1']]) + if change == 'revoked': + internal.fixture.denied.add('document-1') + else: + internal.fixture.held.add('document-1') + result = _run_analyze_on_sources_named_by_an_earlier_run(internal, earlier) + assert result['status'] == 'failed', result + assert not result.get('task_results', {}).get('analyze') + assert not any(call['stage'] in {'window_analysis', 'analysis'} for call in internal.state['calls']) + assert internal.hooks.call_count == 0 diff --git a/functional_tests/test_orchestration_native_results.py b/functional_tests/test_orchestration_native_results.py index 22711ae6f..284ec51be 100644 --- a/functional_tests/test_orchestration_native_results.py +++ b/functional_tests/test_orchestration_native_results.py @@ -1,13 +1,16 @@ # test_orchestration_native_results.py """ Native computation, retained results and authorized export-source integration. -Version: 0.261.141 +Version: 0.261.232 Implemented in: 0.261.127 Replay-location identity failure mapping added in: 0.261.141 +Retained native results limited to container access in: 0.261.232 Only external storage, document metadata, model and telemetry I/O are doubled. The native engine, RunContext, bridge, result store, readers and export bridge execute for real. No Flask route, paid provider or user publication is invoked. +A running native step still checks the tabular file it reads; its retained +result is not rechecked against that file afterwards. """ from contextlib import contextmanager @@ -509,8 +512,8 @@ def test_snapshot_policy_is_explicit_and_retains_original_values(monkeypatch): coverage = runtime.service.open_result(result["task_result"].output("coverage")).read_value() assert coverage["sources"][0]["source_version"] == 1 runtime.native.state["allowed"] = False - with pytest.raises(PermissionError): - runtime.service.open_result(result["task_result"].output("records")) + retained = list(runtime.service.open_result(result["task_result"].output("records")).iter_records()) + assert retained == rows @pytest.mark.parametrize("fault", [ diff --git a/functional_tests/test_orchestration_output_cleanup.py b/functional_tests/test_orchestration_output_cleanup.py index 937cd2d3c..66cfd774c 100644 --- a/functional_tests/test_orchestration_output_cleanup.py +++ b/functional_tests/test_orchestration_output_cleanup.py @@ -1,8 +1,9 @@ # test_orchestration_output_cleanup.py """ Deletion-only cleanup against real output/transport code and storage I/O doubles. -Version: 0.261.127 +Version: 0.261.232 Implemented in: 0.261.127 +Container-only output history covered in: 0.261.232 An owned conversation tombstone or a matching irreversible run deletion guard authorizes cleanup. A missing conversation alone is never deletion authority. @@ -688,7 +689,7 @@ def crash(): assert after["lease"]["token"] == replacement.token and after["attempt_count"] == 1 -@pytest.mark.parametrize("condition", ["denied", "held", "deleted", "storage"]) +@pytest.mark.parametrize("condition", ["denied", "held", "capability", "deleted", "storage"]) def test_nested_output_history_without_any_cards_uses_only_current_authority(cleanup_lifecycle, condition): fixture = cleanup_lifecycle completed = fixture.run(fixture.prepare()) @@ -702,6 +703,8 @@ def test_nested_output_history_without_any_cards_uses_only_current_authority(cle fixture.results.denied.add("document-1") elif condition == "held": fixture.results.held.add("document-1") + elif condition == "capability": + fixture.capabilities = False elif condition == "deleted": delete_conversation(fixture, "deleted") else: @@ -715,6 +718,10 @@ def test_nested_output_history_without_any_cards_uses_only_current_authority(cle outputs = refreshed["metadata"]["orchestration"]["outputs"] if condition == "deleted": assert outputs == [] + elif condition in {"denied", "held"}: + # A retained result's source documents are provenance, not access. + assert len(outputs) == 1 and outputs[0]["available"] is True + assert outputs[0]["artifact_message_id"] == completed["artifact_message_id"] else: assert len(outputs) == 1 and outputs[0]["available"] is False assert outputs[0]["artifact_message_id"] is None and outputs[0]["can_retry"] is False diff --git a/functional_tests/test_orchestration_output_history_routes.py b/functional_tests/test_orchestration_output_history_routes.py index 64305cabf..d88fe5ea4 100644 --- a/functional_tests/test_orchestration_output_history_routes.py +++ b/functional_tests/test_orchestration_output_history_routes.py @@ -1,12 +1,15 @@ # test_orchestration_output_history_routes.py """Verify retained file history and conversation-cleanup classification. -Version: 0.261.127 +Version: 0.261.232 Implemented in: 0.261.127 +Container-only retained-file history covered in: 0.261.232 Exercise conversation ownership, the public screening/history pipeline, current output authorization and committed cards. Only external storage/authority I/O is doubled; history reads must not render, publish or mutate saved messages. +Retained files take their access from their conversation and run, so history +never reads the documents behind their retained results. """ import importlib @@ -71,7 +74,7 @@ def test_only_retained_file_records_bypass_legacy_conversation_purges(history_ru assert message == before -@pytest.mark.parametrize("revocation", ["source", "screening"]) +@pytest.mark.parametrize("revocation", ["source", "screening", "source_producer_deleted"]) def test_history_refreshes_current_siblings_without_writing_or_replaying(history_runtime, revocation): world = history_runtime.world restricted_source = world.retain_source("document-2") @@ -81,23 +84,33 @@ def test_history_refreshes_current_siblings_without_writing_or_replaying(history message["metadata"]["orchestration"]["outputs"][0]["source_ref"] = "PRIVATE cached binding" world.messages.create_item(message) stored_messages = deepcopy(world.messages.items) - writes = (world.runs.sequence, world.results.container.sequence, world.messages.sequence) if revocation == "source": world.results.denied.add("document-2") - else: + elif revocation == "screening": world.results.held.add("document-2") + else: + run = world.runs.read_item(restricted_source.producer.run_id, "conversation-1") + run["checkpoints_deleted"] = True + world.runs.upsert_item(run) + writes = (world.runs.sequence, world.results.container.sequence, world.messages.sequence) response = read_history(history_runtime) body = response.get_json() assert response.status_code == 200, body restored = next(item for item in body["messages"] if item["id"] == message["id"]) current = {item["output_id"]: item for item in restored["metadata"]["orchestration"]["outputs"]} + # 0.261.232: a file takes its access from its conversation and the run that retained its + # input. Revoking or holding a document behind that result does not hide the file. + hidden = revocation == "source_producer_deleted" assert current[restricted["output_id"]]["state"] == "completed" - assert current[restricted["output_id"]]["available"] is False - assert current[restricted["output_id"]]["artifact_message_id"] is None + assert current[restricted["output_id"]]["available"] is not hidden + assert current[restricted["output_id"]]["artifact_message_id"] == ( + None if hidden else restricted["artifact_message_id"] + ) assert current[available["output_id"]]["available"] is True - assert [card["artifact_message_id"] for card in restored["generated_artifacts"]] == [ - available["artifact_message_id"], - ] + assert {card["artifact_message_id"] for card in restored["generated_artifacts"]} == ( + {available["artifact_message_id"]} if hidden + else {available["artifact_message_id"], restricted["artifact_message_id"]} + ) assert "PRIVATE cached binding" not in response.get_data(as_text=True) assert restored["content"] == message["content"] and not restored.get("content_unavailable") assert world.messages.items == stored_messages @@ -107,6 +120,37 @@ def test_history_refreshes_current_siblings_without_writing_or_replaying(history @pytest.mark.parametrize("failure", [ "source_io", "source_unverified", "screening_configuration", "screening_transient", +]) +def test_history_never_reaches_source_services_for_retained_files(history_runtime, failure): + world = history_runtime.world + completed = world.run(world.prepare()) + message = world.output_history() + world.messages.create_item(message) + error = { + "source_io": TimeoutError("PRIVATE source outage"), + "source_unverified": ValueError("PRIVATE malformed authority"), + "screening_configuration": ScreeningConfigurationError("PRIVATE screening configuration"), + "screening_transient": ScreeningError("PRIVATE screening outage"), + }[failure] + calls = [] + + def fail(*args, **kwargs): + calls.append(failure) + raise error + + world.service.results.access.source_metadata_reader = fail + world.service.results.access.source_resolver = fail + response = read_history(history_runtime) + body = response.get_json() + assert response.status_code == 200, body + restored = next(item for item in body["messages"] if item["id"] == message["id"]) + output = restored["metadata"]["orchestration"]["outputs"][0] + assert output["available"] is True and output["artifact_message_id"] == completed["artifact_message_id"] + assert calls == [] and "PRIVATE" not in response.get_data(as_text=True) + assert len(world.render_calls) == world.blobs.uploads == 1 + + +@pytest.mark.parametrize("failure", [ "directory", "output_storage", "result_storage", "message_storage", "configuration", "configuration_invalid", ]) @@ -134,21 +178,12 @@ def fail(*args, **kwargs): world.service.authorize_execution = fail else: - error = { - "source_io": TimeoutError("PRIVATE source outage"), - "source_unverified": ValueError("PRIVATE malformed authority"), - "screening_configuration": ScreeningConfigurationError("PRIVATE screening configuration"), - "screening_transient": ScreeningError("PRIVATE screening outage"), - "directory": ExternalIdentityServiceError("external_identity_timeout"), - }[failure] + error = ExternalIdentityServiceError("external_identity_timeout") def fail(*args, **kwargs): raise error - if failure == "directory": - world.service.authorize_execution = fail - else: - world.service.results.access.source_metadata_reader = fail + world.service.authorize_execution = fail response = read_history(history_runtime) body = response.get_json() assert response.status_code == 503, body diff --git a/functional_tests/test_orchestration_output_lifecycle.py b/functional_tests/test_orchestration_output_lifecycle.py index 14441aa36..acf92b4d3 100644 --- a/functional_tests/test_orchestration_output_lifecycle.py +++ b/functional_tests/test_orchestration_output_lifecycle.py @@ -1,12 +1,15 @@ # test_orchestration_output_lifecycle.py """ Real retained-result/render/transport/commit/download lifecycle integration. -Version: 0.261.127 +Version: 0.261.232 Implemented in: 0.261.127 +Container-only generated-file access covered in: 0.261.232 Production modules (including the complete upload and download modules) run with external Azure I/O doubled. No AST-extracted service, model call, or provider is used. The transactional Cosmos fixture applies the installed SDK's batch format. +A generated file takes its access from its conversation and run: changes to the +documents behind its retained result never fail, hide or withhold it. """ import builtins @@ -35,8 +38,6 @@ from content_screening.contracts import ( ScreeningConfigurationError, ScreeningError, - SourceAuthorityUnavailableError, - SourceAuthorityUnverifiedError, ) from functions_generated_export_contracts import GeneratedFileExportError, GeneratedFileExportRequest from functions_generated_file_exports import build_generated_file_export @@ -722,6 +723,14 @@ def while_staged(): assert "generated_artifact_source" not in encoded and "blob_path" not in encoded assert safe["filename"] == state["file_name"] lifecycle.results.denied.add("document-1") + lifecycle.results.held.add("document-1") + with lifecycle.app.test_request_context(): + kept = lifecycle.modules.sources.sanitize_generated_artifact_history(message, "owner") + kept_cache = lifecycle.modules.sources.sanitize_generated_artifact_history(safe, "owner") + assert not kept.get("content_unavailable") and kept["filename"] == state["file_name"] + assert not kept_cache.get("content_unavailable") + assert json.loads(lifecycle.download(state)) == ROWS + lifecycle.capabilities = False with lifecycle.app.test_request_context(): blocked = lifecycle.modules.sources.sanitize_generated_artifact_history(message, "owner") blocked_cache = lifecycle.modules.sources.sanitize_generated_artifact_history(safe, "owner") @@ -732,7 +741,7 @@ def while_staged(): lifecycle.download(state) with pytest.raises(PermissionError): lifecycle.service.manual_retry(output["output_id"], "revoked-retry") - lifecycle.results.denied.clear() + lifecycle.capabilities = True with lifecycle.app.test_request_context(): restored = lifecycle.modules.sources.sanitize_generated_artifact_history(blocked_cache, "owner") assert not restored.get("content_unavailable") and restored["filename"] == state["file_name"] @@ -844,18 +853,12 @@ def batch(batch_operations, partition_key, **kwargs): assert repeated["state"] == "cancelled" and len(lifecycle.render_calls) == 1 -@pytest.mark.parametrize("revocation", ["source", "screening", "capability"]) @pytest.mark.parametrize("boundary", ["after_render", "after_blob"]) -def test_current_access_is_rechecked_after_render_and_before_commit(lifecycle, revocation, boundary): +def test_capability_is_rechecked_after_render_and_before_commit(lifecycle, boundary): output = lifecycle.prepare() def revoke(): - if revocation == "source": - lifecycle.results.denied.add("document-1") - elif revocation == "screening": - lifecycle.results.held.add("document-1") - else: - lifecycle.capabilities = False + lifecycle.capabilities = False if boundary == "after_render": real = lifecycle.service.renderer @@ -871,10 +874,7 @@ def renderer(**kwargs): state = lifecycle.run(output) assert state["state"] == "failed" and state["automatic_attempts"] == 1 assert state["can_retry"] is False and state["artifact_message_id"] is None - assert state["error_code"] == { - "source": "output_access_denied", "screening": "output_screening_hold", - "capability": "output_capability_disabled", - }[revocation] + assert state["error_code"] == "output_capability_disabled" with pytest.raises((PermissionError, OutputError, ScreeningError)): lifecycle.service.manual_retry(output["output_id"], "not-eligible") lifecycle.now += timedelta(seconds=11) @@ -882,21 +882,40 @@ def renderer(**kwargs): assert not lifecycle.messages.items and not lifecycle.blobs.data +@pytest.mark.parametrize("change", ["source", "screening"]) +@pytest.mark.parametrize("boundary", ["after_render", "after_blob"]) +def test_source_changes_during_render_do_not_fail_the_file(lifecycle, change, boundary): + output = lifecycle.prepare() + + def revoke(): + (lifecycle.results.denied if change == "source" else lifecycle.results.held).add("document-1") + + if boundary == "after_render": + real = lifecycle.service.renderer + + def renderer(**kwargs): + rendered = real(**kwargs) + revoke() + return rendered + + lifecycle.service.renderer = renderer + else: + lifecycle.blobs.after_upload = revoke + state = lifecycle.run(output) + assert state["state"] == "completed" and state["available"] is True + assert state["artifact_message_id"] and state["automatic_attempts"] == 1 + assert json.loads(lifecycle.download(state)) == ROWS + assert lifecycle.results.source_reads == [] + + @pytest.mark.parametrize("revocation", [ - "source", "screening", "source_deleted", "capability", "owner", - "conversation_deleted", "run_deleted", "superseded", "step_disabled", + "capability", "owner", "conversation_deleted", "run_deleted", "superseded", "step_disabled", ]) def test_completed_download_history_and_retry_reauthorize_current_state(lifecycle, revocation): output = lifecycle.run(lifecycle.prepare()) record = lifecycle.raw(output) message = lifecycle.service.transport.message(record, committed=True) - if revocation == "source": - lifecycle.results.denied.add("document-1") - elif revocation == "screening": - lifecycle.results.held.add("document-1") - elif revocation == "source_deleted": - lifecycle.results.sources.clear() - elif revocation == "capability": + if revocation == "capability": lifecycle.capabilities = False elif revocation in {"owner", "conversation_deleted"}: conversation = lifecycle.conversations.read_item("conversation-1", "conversation-1") @@ -924,18 +943,34 @@ def test_completed_download_history_and_retry_reauthorize_current_state(lifecycl assert len(lifecycle.render_calls) == lifecycle.blobs.uploads == 1 +@pytest.mark.parametrize("change", ["source", "screening", "source_deleted", "source_changed"]) +def test_completed_file_download_and_history_survive_source_changes(lifecycle, change): + output = lifecycle.run(lifecycle.prepare()) + message = lifecycle.service.transport.message(lifecycle.raw(output), committed=True) + if change == "source": + lifecycle.results.denied.add("document-1") + elif change == "screening": + lifecycle.results.held.add("document-1") + elif change == "source_deleted": + lifecycle.results.sources.clear() + else: + lifecycle.results.sources["document-1"]["source_version"] = 2 + assert json.loads(lifecycle.download(output)) == ROWS + with lifecycle.app.test_request_context(): + safe = lifecycle.modules.sources.sanitize_generated_artifact_history(message, "owner") + assert not safe.get("content_unavailable") and safe["filename"] == output["file_name"] + projection = lifecycle.service.list_public_outputs("run-1")[0] + assert projection["available"] is True and projection["artifact_message_id"] == output["artifact_message_id"] + assert len(lifecycle.render_calls) == lifecycle.blobs.uploads == 1 + assert lifecycle.results.source_reads == [] + + @pytest.mark.parametrize("revocation,reason", [ - ("source", "output_access_denied"), - ("screening", "output_screening_hold"), - ("source_deleted", "output_source_changed"), - ("source_changed", "output_source_changed"), - ("source_digest_changed", "output_source_changed"), ("retained_deleted", "output_source_unavailable"), ("retained_guard_missing", "output_source_unavailable"), ("retained_commit_missing", "output_source_unavailable"), ("retained_manifest_missing", "output_source_unavailable"), ("source_producer_deleted", "output_source_unavailable"), - ("source_metadata_missing", "output_source_unavailable"), ("message_missing", "output_artifact_missing"), ("capability", "output_capability_disabled"), ]) @@ -952,17 +987,7 @@ def test_public_output_list_preserves_authorized_siblings(lifecycle, revocation, visible_before = lifecycle.raw(visible) source_run = second_source.producer.run_id store = lifecycle.service.results.store - if revocation == "source": - lifecycle.results.denied.add("document-2") - elif revocation == "screening": - lifecycle.results.held.add("document-2") - elif revocation == "source_deleted": - del lifecycle.results.sources["document-2"] - elif revocation == "source_changed": - lifecycle.results.sources["document-2"]["source_version"] = 2 - elif revocation == "source_digest_changed": - lifecycle.results.sources["document-2"]["content_sha256"] = "2" * 64 - elif revocation == "retained_deleted": + if revocation == "retained_deleted": binding = store.prepare_orchestration_result( "owner", "conversation-1", source_run, "analyze", guard_token="server-attempt-token", ) @@ -987,15 +1012,6 @@ def test_public_output_list_preserves_authorized_siblings(lifecycle, revocation, run = lifecycle.runs.read_item(source_run, "conversation-1") run["checkpoints_deleted"] = True lifecycle.runs.upsert_item(run) - elif revocation == "source_metadata_missing": - original = lifecycle.service.results.access.source_metadata_reader - - def metadata(document_id, **kwargs): - if document_id == "document-2": - raise LookupError("Private source disappeared between current ACL and metadata reads.") - return original(document_id, **kwargs) - - lifecycle.service.results.access.source_metadata_reader = metadata elif revocation == "message_missing": message = lifecycle.messages.read_item(hidden["artifact_message_id"], "conversation-1") lifecycle.messages.delete_item(message["id"], "conversation-1", etag=message["_etag"]) @@ -1026,14 +1042,44 @@ def metadata(document_id, **kwargs): assert private not in json.dumps(projections) -@pytest.mark.parametrize("revocation", ["source", "screening"]) -def test_public_outputs_restore_current_access_without_rerendering(lifecycle, revocation): +@pytest.mark.parametrize("change", [ + "source", "screening", "source_deleted", "source_changed", "source_digest_changed", "source_metadata_missing", +]) +def test_public_output_list_ignores_changes_to_retained_sources(lifecycle, change): + second_source = lifecycle.retain_source("document-2") + first = lifecycle.run(lifecycle.prepare("json", reference=second_source)) + second = lifecycle.run(lifecycle.prepare("csv")) + if change == "source": + lifecycle.results.denied.add("document-2") + elif change == "screening": + lifecycle.results.held.add("document-2") + elif change == "source_deleted": + del lifecycle.results.sources["document-2"] + elif change == "source_changed": + lifecycle.results.sources["document-2"]["source_version"] = 2 + elif change == "source_digest_changed": + lifecycle.results.sources["document-2"]["content_sha256"] = "2" * 64 + else: + def metadata(*args, **kwargs): + raise LookupError("Private source disappeared between current ACL and metadata reads.") + + lifecycle.service.results.access.source_metadata_reader = metadata + projections = lifecycle.service.list_public_outputs("run-1") + cards = lifecycle.service.committed_artifacts("run-1") + assert [projection["available"] for projection in projections] == [True, True] + assert {card["artifact_message_id"] for card in cards} == { + first["artifact_message_id"], second["artifact_message_id"], + } + assert b"last" in lifecycle.download(first) + assert lifecycle.render_calls == [("json", "exact_records_v1"), ("csv", "tabular_records_v1")] + + +def test_public_outputs_restore_capability_without_rerendering(lifecycle): hidden = lifecycle.run(lifecycle.prepare()) - collection = lifecycle.results.denied if revocation == "source" else lifecycle.results.held - collection.add("document-1") + lifecycle.capabilities = False denied = lifecycle.service.list_public_outputs("run-1") unavailable_cards = lifecycle.service.committed_artifacts("run-1") - collection.clear() + lifecycle.capabilities = True lifecycle.restart() writes = lifecycle.results.container.sequence restored = lifecycle.service.list_public_outputs("run-1") @@ -1048,13 +1094,16 @@ def test_public_outputs_restore_current_access_without_rerendering(lifecycle, re @pytest.mark.parametrize("state", ["waiting", "retry_scheduled", "failed"]) -def test_public_outputs_withhold_retry_controls_after_source_revocation(lifecycle, state): +def test_public_outputs_withhold_retry_controls_while_the_capability_is_off(lifecycle, state): output = lifecycle.prepare() lifecycle.failures["json"] = [TimeoutError("Private transport failure")] * 3 for _ in range({"waiting": 0, "retry_scheduled": 1, "failed": 3}[state]): output = lifecycle.run(output) lifecycle.advance_due(output) lifecycle.results.denied.add("document-1") + kept = lifecycle.service.list_public_outputs("run-1") + assert kept[0]["state"] == state and kept[0]["available"] is True + lifecycle.capabilities = False projections = lifecycle.service.list_public_outputs("run-1") cards = lifecycle.service.committed_artifacts("run-1") assert projections[0]["state"] == state and projections[0]["available"] is False @@ -1064,17 +1113,13 @@ def test_public_outputs_withhold_retry_controls_after_source_revocation(lifecycl lifecycle.service.manual_retry(output["output_id"], "denied-request") -@pytest.mark.parametrize("boundary", [ - "outputs", "results", "messages", "source_network", "source_wrapped_network", - "screening_configuration", "screening_service", "unexpected_callback", "source_reader_missing", - "metadata_key_error", -]) -@pytest.mark.parametrize("operation", [ +_OUTPUT_READS = [ "list_public_outputs", "committed_artifacts", "history_file", "history_cached_file", "history_card", "history_outputs", "history_cached_outputs", "history_top_level_card", -]) -def test_public_output_reads_propagate_infrastructure_failures(lifecycle, boundary, operation): - completed = lifecycle.run(lifecycle.prepare()) +] + + +def _output_read_message(lifecycle, completed, operation): message = lifecycle.service.transport.message(lifecycle.raw(completed), committed=True) if operation == "history_cached_file": with lifecycle.app.test_request_context(): @@ -1094,18 +1139,48 @@ def test_public_output_reads_propagate_infrastructure_failures(lifecycle, bounda elif operation == "history_top_level_card": message = lifecycle.output_history() del message["metadata"]["orchestration"]["outputs"] - expected = (AzureError, OutputStorageError) + return message + + +def _read_outputs(lifecycle, operation, message): + if operation.startswith("history_"): + with lifecycle.app.test_request_context(): + return lifecycle.modules.sources.sanitize_generated_artifact_history(message, "owner") + return getattr(lifecycle.service, operation)("run-1") + + +@pytest.mark.parametrize("boundary", ["outputs", "results", "messages"]) +@pytest.mark.parametrize("operation", _OUTPUT_READS) +def test_public_output_reads_propagate_infrastructure_failures(lifecycle, boundary, operation): + completed = lifecycle.run(lifecycle.prepare()) + message = _output_read_message(lifecycle, completed, operation) if boundary == "outputs": lifecycle.runs.fail_reads = True elif boundary == "results": lifecycle.results.container.fail_reads = True - elif boundary == "messages": + else: lifecycle.messages.fail_reads = True - elif boundary == "source_reader_missing": - lifecycle.service.results.access.source_resolver = None - expected = OutputError + with pytest.raises((AzureError, OutputStorageError)): + _read_outputs(lifecycle, operation, message) + assert len(lifecycle.render_calls) == lifecycle.blobs.uploads == 1 + + +@pytest.mark.parametrize("boundary", [ + "source_network", "source_wrapped_network", "screening_configuration", "screening_service", + "unexpected_callback", "source_reader_missing", "metadata_key_error", +]) +@pytest.mark.parametrize("operation", _OUTPUT_READS) +def test_public_output_reads_never_reach_source_services(lifecycle, boundary, operation): + completed = lifecycle.run(lifecycle.prepare()) + message = _output_read_message(lifecycle, completed, operation) + calls = [] + access = lifecycle.service.results.access + if boundary == "source_reader_missing": + access.source_resolver = None + access.source_metadata_reader = None else: def fail(*args, **kwargs): + calls.append(boundary) if boundary == "source_network": raise TimeoutError("private-source-endpoint") if boundary == "source_wrapped_network": @@ -1118,45 +1193,29 @@ def fail(*args, **kwargs): raise KeyError("private unexpected metadata field") raise RuntimeError("private unexpected callback details") - lifecycle.service.results.access.source_metadata_reader = fail - expected = { - "source_network": SourceAuthorityUnavailableError, "source_wrapped_network": OutputStorageError, - "screening_configuration": ScreeningConfigurationError, "screening_service": ScreeningError, - "unexpected_callback": SourceAuthorityUnverifiedError, - "metadata_key_error": SourceAuthorityUnverifiedError, - }[boundary] - with pytest.raises(expected): - if operation.startswith("history_"): - with lifecycle.app.test_request_context(): - lifecycle.modules.sources.sanitize_generated_artifact_history(message, "owner") - else: - getattr(lifecycle.service, operation)("run-1") + access.source_metadata_reader = fail + access.source_resolver = fail + result = _read_outputs(lifecycle, operation, message) + assert calls == [] + assert "content_unavailable" not in json.dumps(result) assert len(lifecycle.render_calls) == lifecycle.blobs.uploads == 1 -@pytest.mark.parametrize("failure_type,retryable,code", [ - (TimeoutError, True, "source_authority_unavailable"), - (RuntimeError, False, "source_authority_unverified"), - (ScreeningError, True, "output_screening_unavailable"), - (ScreeningConfigurationError, False, "output_screening_unavailable"), -]) -def test_current_source_service_failures_preserve_retry_classification(lifecycle, failure_type, retryable, code): +@pytest.mark.parametrize("failure_type", [TimeoutError, RuntimeError, ScreeningError, ScreeningConfigurationError]) +def test_source_service_failures_never_reach_a_retained_file_render(lifecycle, failure_type): output = lifecycle.prepare() + calls = [] def unavailable(*args, **kwargs): + calls.append(failure_type) raise failure_type("Private source service details") lifecycle.service.results.access.source_metadata_reader = unavailable - for attempt in range(1, 4 if retryable else 2): - result = lifecycle.run(output) - if retryable and attempt < 3: - assert result["state"] == "retry_scheduled" and result["automatic_attempts"] == attempt + 1 - lifecycle.advance_due(output) - else: - assert result["state"] == "failed" and result["can_retry"] is retryable - assert result["error_code"] == code and "Private source" not in json.dumps(result) - assert not lifecycle.render_calls and not lifecycle.blobs.data - + lifecycle.service.results.access.source_resolver = unavailable + result = lifecycle.run(output) + assert result["state"] == "completed" and result["available"] is True + assert calls == [] and "Private source" not in json.dumps(result) + assert lifecycle.render_calls == [("json", "exact_records_v1")] @pytest.mark.parametrize("change", ["owner", "conversation_deleted"]) def test_public_output_list_never_downgrades_conversation_denial_to_file_placeholder(lifecycle, change): @@ -1171,8 +1230,7 @@ def test_public_output_list_never_downgrades_conversation_denial_to_file_placeho @pytest.mark.parametrize("card_location", ["none", "legacy", "top_level", "both"]) -@pytest.mark.parametrize("revocation", ["source", "screening", "source_deleted"]) -def test_nested_output_history_refreshes_denied_siblings_before_hydration(lifecycle, card_location, revocation): +def test_nested_output_history_refreshes_denied_siblings_before_hydration(lifecycle, card_location): second_source = lifecycle.retain_source("document-2") hidden = lifecycle.run(lifecycle.prepare("json", reference=second_source)) visible = lifecycle.run(lifecycle.prepare("csv")) @@ -1182,12 +1240,11 @@ def test_nested_output_history_refreshes_denied_siblings_before_hydration(lifecy message["metadata"]["orchestration"]["outputs"][0]["source_ref"] = {"blob_path": "private-cache-locator"} message["metadata"]["orchestration"]["outputs"][0]["can_retry"] = True before = deepcopy(message) - if revocation == "source": - lifecycle.results.denied.add("document-2") - elif revocation == "screening": - lifecycle.results.held.add("document-2") - else: - del lifecycle.results.sources["document-2"] + # A changed or held source hides nothing; the capability decision hides one sibling. + lifecycle.results.denied.add("document-2") + lifecycle.results.held.add("document-2") + real_authorize = lifecycle.service.authorize_execution + lifecycle.service.authorize_execution = lambda record, **kwargs: record["id"] != hidden["output_id"] with lifecycle.app.test_request_context(): safe = lifecycle.modules.sources.sanitize_generated_artifact_history(message, "owner") screening_error = getattr(g, "content_screening_error", None) @@ -1208,9 +1265,7 @@ def test_nested_output_history_refreshes_denied_siblings_before_hydration(lifecy cards = safe["metadata"]["generated_orchestration_outputs"] assert cards[0]["status"] == "unavailable" and "artifact_message_id" not in cards[0] assert cards[1]["artifact_message_id"] == visible["artifact_message_id"] - lifecycle.results.denied.clear() - lifecycle.results.held.clear() - lifecycle.results.sources["document-2"] = {**source("document-2"), "content_sha256": "1" * 64} + lifecycle.service.authorize_execution = real_authorize with lifecycle.app.test_request_context(): restored = lifecycle.modules.sources.sanitize_generated_artifact_history(safe, "owner") restored_by_id = { @@ -1261,12 +1316,16 @@ def test_nested_history_rechecks_saved_retry_controls(lifecycle, state): lifecycle.advance_due(output) cached = lifecycle.output_history(with_cards=False) lifecycle.results.denied.add("document-1") + with lifecycle.app.test_request_context(): + kept = lifecycle.modules.sources.sanitize_generated_artifact_history(cached, "owner") + assert kept["metadata"]["orchestration"]["outputs"][0]["available"] is True + lifecycle.capabilities = False with lifecycle.app.test_request_context(): safe = lifecycle.modules.sources.sanitize_generated_artifact_history(cached, "owner") masked = safe["metadata"]["orchestration"]["outputs"][0] assert masked["state"] == state and masked["available"] is False assert masked["can_retry"] is False and masked["next_retry_at"] is None - lifecycle.results.denied.clear() + lifecycle.capabilities = True lifecycle.now = lifecycle.deadline + timedelta(seconds=1) with lifecycle.app.test_request_context(): expired = lifecycle.modules.sources.sanitize_generated_artifact_history(cached, "owner") diff --git a/functional_tests/test_orchestration_render_check_cadence.py b/functional_tests/test_orchestration_render_check_cadence.py index b61c3e0a9..e2c8688e1 100644 --- a/functional_tests/test_orchestration_render_check_cadence.py +++ b/functional_tests/test_orchestration_render_check_cadence.py @@ -2,14 +2,15 @@ # test_orchestration_render_check_cadence.py """ Functional test for paced render execution checks and step time limits during rendering. -Version: 0.261.141 +Version: 0.261.232 Implemented in: 0.261.141 +Container-only retained-result access covered in: 0.261.232 This test ensures that a file render re-proves its claim, run, capability admission and -source access on a bounded cadence instead of on every record or block, that revocation -and lease renewal still happen on time, that publication boundaries keep their own full -checks, and that the owning step's time limit stops an unstaged render cleanly with a -clear, non-retryable failure while a staged file is always committed. +retained-result access on a bounded cadence instead of on every record or block, that +revocation and lease renewal still happen on time, that publication boundaries keep their +own full checks, and that the owning step's time limit stops an unstaged render cleanly +with a clear, non-retryable failure while a staged file is always committed. """ import importlib @@ -20,7 +21,7 @@ import pytest -from functions_orchestration_output_store import OutputError +from functions_orchestration_output_store import OutputError, OutputUnavailableError from functions_orchestration_rendering import ( OrchestrationRenderingService, OutputStepTimeLimitError, @@ -154,7 +155,7 @@ def long_wall_clock(kwargs): def test_renderer_source_rechecks_are_paced_but_revocation_is_still_refused(lifecycle): - print("🔍 Testing renderer source rechecks are paced without weakening the final proof...") + print("🔍 Testing renderer retained-result rechecks are paced without weakening the final proof...") _paced(lifecycle) output = lifecycle.prepare() facts = [] @@ -172,16 +173,20 @@ def rechecking(kwargs): revoked = lifecycle.prepare("csv") def revoking(kwargs): + # 0.261.232: a retained result takes its access from its conversation and run, + # so losing the conversation (not a source document) is what must be refused. kwargs["source"].recheck() - lifecycle.results.denied.add("document-1") + conversation = lifecycle.conversations.read_item("conversation-1", "conversation-1") + conversation["orchestration_deleted"] = True + lifecycle.conversations.upsert_item(conversation) kwargs["source"].recheck() _wrap_renderer(lifecycle, revoking) uploads = lifecycle.blobs.uploads - state = lifecycle.run(revoked) - assert state["state"] == "failed" and state["error_code"] == "output_access_denied" + with pytest.raises(OutputUnavailableError): + lifecycle.run(revoked) assert lifecycle.blobs.uploads == uploads - print("✅ Rechecks were paced and the revoked source was still refused.") + print("✅ Rechecks were paced and the lost conversation was still refused.") def test_step_stop_fails_the_file_before_rendering_without_publishing(lifecycle): diff --git a/functional_tests/test_orchestration_render_resume.py b/functional_tests/test_orchestration_render_resume.py index d548a78e1..254ee7952 100644 --- a/functional_tests/test_orchestration_render_resume.py +++ b/functional_tests/test_orchestration_render_resume.py @@ -1,12 +1,15 @@ # test_orchestration_render_resume.py """ Observe checkpoint-pinned Render files without performing scheduler work. -Version: 0.261.127 +Version: 0.261.232 Implemented in: 0.261.127 +Container-only file access covered in: 0.261.232 -Real output persistence, source authorization, committed messages and StepResult -builders exercise exact resume bindings and current readiness. Retry claims, -rendering, model execution and stale completion fallbacks are forbidden. +Real output persistence, retained-result authorization, committed messages and +StepResult builders exercise exact resume bindings and current readiness. Retry +claims, rendering, model execution and stale completion fallbacks are forbidden. +A file takes its access from its conversation and run, so changes to the +documents behind its retained result never fail its resumption. """ import importlib @@ -612,17 +615,13 @@ def test_resumption_propagates_operational_read_failures_without_a_replacement_r assert saved == original_saved -@pytest.mark.parametrize("failure", ["source", "screening", "message", "capability", "output_storage"]) +@pytest.mark.parametrize("failure", ["message", "capability", "output_storage"]) @pytest.mark.parametrize("saved_kind", ["waiting", "completed"]) def test_resumption_rechecks_current_readiness_not_cached_completion(resumption, monkeypatch, failure, saved_kind): case, world = resumption, resumption.world completed = world.run(world.prepare()) before = world.raw(completed) - if failure == "source": - world.results.denied.add("document-1") - elif failure == "screening": - world.results.held.add("document-1") - elif failure == "message": + if failure == "message": world.messages.delete_item(completed["artifact_message_id"], "conversation-1") elif failure == "capability": world.capabilities = False @@ -641,7 +640,7 @@ def test_resumption_rechecks_current_readiness_not_cached_completion(resumption, assert result["status"] == "failed" and result["artifacts"] == [] and result["output_error"] assert completed["artifact_message_id"] not in json.dumps(result) assert "PRIVATE" not in json.dumps(result) - if failure in {"source", "screening", "capability"}: + if failure == "capability": assert result["outputs"][0]["available"] is False assert result["outputs"][0]["artifact_message_id"] is None assert result["failure"]["code"] == "result_unavailable" @@ -649,6 +648,34 @@ def test_resumption_rechecks_current_readiness_not_cached_completion(resumption, assert before == after and len(world.render_calls) == world.blobs.uploads == 1 +@pytest.mark.parametrize("change", ["source", "screening", "source_deleted", "source_changed"]) +@pytest.mark.parametrize("saved_kind", ["waiting", "completed"]) +def test_resumption_ignores_changes_to_the_retained_result_sources(resumption, monkeypatch, change, saved_kind): + # 0.261.232: a file takes its access from its conversation and run, not from the + # documents behind its retained result. + case, world = resumption, resumption.world + completed = world.run(world.prepare()) + before = world.raw(completed) + if change == "source": + world.results.denied.add("document-1") + elif change == "screening": + world.results.held.add("document-1") + elif change == "source_deleted": + world.results.sources.clear() + else: + world.results.sources["document-1"]["source_version"] = 2 + forbid_execution(case, monkeypatch) + saved = pending_result(case, completed) if saved_kind == "waiting" else completed_result(case, completed) + result = resume(case, saved) + after = world.raw(completed) + assert result["status"] == "completed" and result["failure"] is None + assert result["outputs"] == [completed] + assert result["artifacts"][0]["artifact_message_id"] == completed["artifact_message_id"] + assert "PRIVATE" not in json.dumps(result) + assert world.results.source_reads == [] + assert before == after and len(world.render_calls) == world.blobs.uploads == 1 + + @pytest.mark.parametrize("boundary", ["factory", "inputs", "authority", "output_storage"]) @pytest.mark.parametrize("completed", [False, True]) def test_resumption_preserves_the_saved_checkpoint_during_uncertainty( @@ -735,17 +762,13 @@ def test_resumption_does_not_overwrite_a_full_saved_wait_after_a_failed_read( assert saved == original_saved -@pytest.mark.parametrize("failure", ["source", "screening", "capability", "cancelled", "identity", "configuration"]) +@pytest.mark.parametrize("failure", ["capability", "cancelled", "identity", "configuration"]) def test_resumption_distinguishes_denial_cancellation_and_nonretryable_uncertainty(resumption, monkeypatch, failure): case, world = resumption, resumption.world output = world.run(world.prepare()) saved = world.modules.schema.build_step_result(status="completed", summary="The file was ready.") saved["outputs"] = [deepcopy(output)] - if failure == "source": - world.results.denied.add("document-1") - elif failure == "screening": - world.results.held.add("document-1") - elif failure == "capability": + if failure == "capability": world.capabilities = False elif failure == "cancelled": case.cancel_requested = lambda: True diff --git a/functional_tests/test_orchestration_result_imports.py b/functional_tests/test_orchestration_result_imports.py index 424db8ba5..c7af93e6e 100644 --- a/functional_tests/test_orchestration_result_imports.py +++ b/functional_tests/test_orchestration_result_imports.py @@ -1,8 +1,9 @@ # test_orchestration_result_imports.py """ Real-module result-store cold imports and web/scheduler compatibility. -Version: 0.261.125 +Version: 0.261.232 Implemented in: 0.261.125 +Input-only source authorizer name updated in: 0.261.232 Fresh normal/optimized interpreters block network access. Only external I/O is doubled; config, settings, app, scheduler and result modules are never replaced. @@ -103,7 +104,7 @@ def checkpoints(producer, previous=None): store=native.service.store, resume_run_id=previous, authorize=lambda: native.service.access.authorize_producer(producer, for_write=True), source_authorizer=lambda user_id, sources, require_snapshot: ( - native.service.access.authorize_sources(sources, require_snapshot=require_snapshot) + native.service.access.authorize_input_sources(sources, require_snapshot=require_snapshot) ), ) first = checkpoints(original) diff --git a/functional_tests/test_orchestration_result_recovery.py b/functional_tests/test_orchestration_result_recovery.py index 6c5514602..e5444d9cf 100644 --- a/functional_tests/test_orchestration_result_recovery.py +++ b/functional_tests/test_orchestration_result_recovery.py @@ -1,11 +1,13 @@ # test_orchestration_result_recovery.py """ Native resumed guards and exact committed-result recovery for orchestration. -Version: 0.261.125 +Version: 0.261.232 Implemented in: 0.261.125 +Container-only committed-result recovery covered in: 0.261.232 Exercise real checkpoint/transport/facade APIs with external I/O doubled. -The parent integration owns the next application version increment. +Native checkpoints still check the documents they read as inputs; a committed +result is recovered with its conversation's and run's access only. """ from copy import deepcopy @@ -43,7 +45,7 @@ def native_checkpoints(fixture, producer, *, previous=None): store=fixture.service.store, resume_run_id=previous, authorize=lambda: fixture.service.access.authorize_producer(producer, for_write=True), source_authorizer=lambda user_id, sources, require_snapshot: ( - fixture.service.access.authorize_sources(sources, require_snapshot=require_snapshot) + fixture.service.access.authorize_input_sources(sources, require_snapshot=require_snapshot) ), ) @@ -297,21 +299,34 @@ def test_partial_task_receipt_preserves_unreadable_failure_output_without_promot fixture.service.open_result(recovered.output("failed"), allow_partial=True) -@pytest.mark.parametrize("change", ["source_deleted", "screening", "revision", "owner", "scope", "attempt", "cleanup"]) -def test_receipt_recovery_reauthorizes_current_source_and_producer_state(change): +@pytest.mark.parametrize("change", ["source_deleted", "screening", "revision", "scope"]) +def test_receipt_recovery_keeps_container_access_when_sources_change(change): + # 0.261.232: a committed result takes its access from its conversation and run; + # the documents it was produced from are provenance and are not reread. fixture = ResultFixture() checkpoints = native_checkpoints(fixture, fixture.producer) - persist_native_output(fixture, fixture.producer, checkpoints, input_fingerprint=INPUT_FINGERPRINT) + committed = persist_native_output(fixture, fixture.producer, checkpoints, input_fingerprint=INPUT_FINGERPRINT) if change == "source_deleted": fixture.sources.clear() elif change == "screening": fixture.held.add("document-1") elif change == "revision": fixture.sources["document-1"]["source_revision"] = "new-revision" - elif change == "owner": - fixture.conversation["user_id"] = "foreign" - elif change == "scope": + else: fixture.sources["document-1"].update(scope="group", scope_id="foreign-group") + reads = len(fixture.source_reads) + recovered = fixture.restart().recover_task_result(producer=fixture.producer, input_fingerprint=INPUT_FINGERPRINT) + assert recovered == committed + assert len(fixture.source_reads) == reads + + +@pytest.mark.parametrize("change", ["owner", "attempt", "cleanup"]) +def test_receipt_recovery_reauthorizes_current_producer_state(change): + fixture = ResultFixture() + checkpoints = native_checkpoints(fixture, fixture.producer) + persist_native_output(fixture, fixture.producer, checkpoints, input_fingerprint=INPUT_FINGERPRINT) + if change == "owner": + fixture.conversation["user_id"] = "foreign" elif change == "attempt": fixture.runs["run-1"]["attempt_index"] = 2 else: diff --git a/functional_tests/test_orchestration_results.py b/functional_tests/test_orchestration_results.py index d46604861..7081611e4 100644 --- a/functional_tests/test_orchestration_results.py +++ b/functional_tests/test_orchestration_results.py @@ -1,11 +1,14 @@ # test_orchestration_results.py """ M1 authorized retained-result persistence, complete readers, and lifecycle fences. -Version: 0.261.125 +Version: 0.261.232 Implemented in: 0.261.125 +Container-only retained-result access covered in: 0.261.232 -Real contracts, facade, record trees, transport, source/screening checks and SDK- -formatted conditional batches run with external Cosmos/Blob/source I/O doubled. +Real contracts, facade, record trees, transport, producer checks and SDK- +formatted conditional batches run with external Cosmos/Blob I/O doubled. A +retained result takes its access from its conversation and run; the documents +it was produced from are provenance and are never reread. """ from copy import deepcopy @@ -14,7 +17,6 @@ import pytest -from content_screening.contracts import ScreeningError from functions_generated_file_exports import GeneratedFileExportRequest, build_generated_file_export from functions_orchestration_result_contracts import ( InputBinding, InputSpec, RecordColumn, ResultContractError, ResultRef, TaskResult, canonical_bytes, @@ -162,7 +164,7 @@ def test_result_producer_owner_scope_and_attempt_mismatch_fail(field, value): @pytest.mark.parametrize("change", ["deleted", "revoked", "scope", "revision", "version", "screening"]) -def test_current_source_access_is_checked_on_consumption_not_only_save(change): +def test_source_changes_never_withhold_a_retained_result(change): fixture = ResultFixture() reference = fixture.save().output("findings") reader = fixture.service.open_result(reference) @@ -178,29 +180,30 @@ def test_current_source_access_is_checked_on_consumption_not_only_save(change): fixture.sources["document-1"]["source_version"] = 2 else: fixture.held.add("document-1") - with pytest.raises((PermissionError, ScreeningError)): - list(reader.iter_records()) - with pytest.raises((PermissionError, ScreeningError)): - fixture.restart().open_result(reference) + assert list(reader.iter_records()) == ROWS + restarted = fixture.restart().open_result(reference, require_current_sources=True) + assert list(restarted.iter_records()) == ROWS + assert restarted.metadata()["source_count"] == 1 + assert fixture.source_reads == [] -def test_explicit_historical_snapshot_never_refreshes_or_hides_changes(): +def test_snapshot_and_current_policies_read_the_same_values_after_a_source_changes(): fixture = ResultFixture() reference = fixture.save(source_policy="snapshot").output("findings") fixture.sources["document-1"]["source_revision"] = "changed-revision" reader = fixture.restart().open_result(reference) metadata = reader.metadata() rows = list(reader.iter_records()) - assert metadata["source_snapshot_changed"] is True + assert metadata["source_snapshot_changed"] is False assert metadata["source_policy"] == "snapshot" and rows == ROWS - with pytest.raises(PermissionError): - fixture.service.open_result(reference, require_current_sources=True) + current = fixture.service.open_result(reference, require_current_sources=True) + assert list(current.iter_records()) == ROWS fixture.held.add("document-1") - with pytest.raises((PermissionError, ScreeningError)): - reader.recheck() + reader.recheck() + assert fixture.source_reads == [] -def test_current_source_content_digest_is_not_ignored(): +def test_changed_source_content_digest_does_not_withhold_a_retained_result(): fixture = ResultFixture() snapshot = {**source(), "content_sha256": "a" * 64} fixture.sources["document-1"] = deepcopy(snapshot) @@ -210,8 +213,7 @@ def test_current_source_content_digest_is_not_ignored(): sources=[snapshot], origin="grounded", guard_token="server-attempt-token", ) fixture.sources["document-1"]["content_sha256"] = "b" * 64 - with pytest.raises(PermissionError): - fixture.service.open_result(task.output("result")) + assert list(fixture.service.open_result(task.output("result")).iter_records()) == ROWS @pytest.mark.parametrize("kind", ["source-set-v1", "evidence-set-v1"]) @@ -313,6 +315,8 @@ def test_upstream_lineage_and_explicit_aliases_reauthorize_without_cross_attempt outputs=[NamedOutput("draft", "text-v1", "Prepared from all original findings.", complete(1))], ) fixture.denied.add("document-1") + assert fixture.restart().open_result(child.output("draft")).read_text() == "Prepared from all original findings." + fixture.runs["run-1"]["plan"]["steps"][0]["enabled"] = False with pytest.raises(PermissionError): fixture.restart().open_result(child.output("draft")) @@ -543,5 +547,7 @@ def save_result(user_id, conversation_id, run_id, step_id, value, *, settings): assert metadata["sources"] == [source()] and metadata["coverage"] == native["coverage"] assert "original_sources_reanalyzed" in metadata and metadata["original_sources_reanalyzed"] is False fixture.denied.add("document-1") + assert len(list(adapter.iter_records())) == 150 + fixture.conversation["orchestration_deleted"] = True with pytest.raises(PermissionError): list(adapter.iter_records()) diff --git a/functional_tests/test_orchestration_source_access.py b/functional_tests/test_orchestration_source_access.py index ac629e394..bcd02e0f0 100644 --- a/functional_tests/test_orchestration_source_access.py +++ b/functional_tests/test_orchestration_source_access.py @@ -1,13 +1,15 @@ # test_orchestration_source_access.py """ -Strict current-source authority errors for retained orchestration results. -Version: 0.261.143 +Strict current-source authority errors for orchestration input reads. +Version: 0.261.232 Implemented in: 0.261.127 Authority check reason codes added in: 0.261.141 Revision conflict reason codes added in: 0.261.143 +Retained results limited to container access in: 0.261.232 Exercise real screening, source resolution, retained facade and alias discovery. -Only external storage, membership, model and telemetry I/O are doubled. +Only external storage, membership, model and telemetry I/O are doubled. Input +reads keep typed authority failures; retained results never call source authority. """ from concurrent.futures import ThreadPoolExecutor @@ -49,7 +51,7 @@ resolve_orchestration_source_manifest, ) from test_content_screening_access import ScreeningAccessFixture, fake_module -from test_support.orchestration_results import ResultFixture +from test_support.orchestration_results import ResultFixture, source def no_network(*args, **kwargs): @@ -601,24 +603,30 @@ def test_strict_manifest_cancellation_is_not_reclassified_as_authority_error(): @pytest.mark.parametrize("failure_type", [SourceAuthorityUnavailableError, SourceAuthorityUnverifiedError]) -def test_real_retained_facade_and_root_discovery_propagate_operational_errors(monkeypatch, failure_type): +def test_retained_facade_and_root_discovery_never_call_source_authority(monkeypatch, failure_type): fixture = ResultFixture() task = fixture.save() record = fixture.runs["run-1"] record["plan"]["planner_contract_version"] = 2 record["task_results"] = {"analyze": task.to_dict()} + calls = [] - def unavailable(**kwargs): + def unavailable(*args, **kwargs): + calls.append(args) raise failure_type() monkeypatch.setattr(fixture, "metadata", unavailable) reopened = fixture.restart() + discovered = discover_result_aliases([deepcopy(record)], reopened) + assert list(discovered["aliases"].values()) == [task.output("findings")] + assert discovered["unavailable_count"] == 0 and calls == [] with pytest.raises(failure_type): - discover_result_aliases([deepcopy(record)], reopened) + reopened.access.authorize_input_sources([source()], require_snapshot=True) + assert calls @pytest.mark.parametrize("malformation", ["missing", "order", "scope", "status", "version"]) -def test_retained_facade_rejects_malformed_fresh_resolver_authority(monkeypatch, malformation): +def test_input_reads_reject_malformed_fresh_resolver_authority(monkeypatch, malformation): fixture = ResultFixture() task = fixture.save() fresh = fixture.resolve(["document-1"]) @@ -635,26 +643,24 @@ def test_retained_facade_rejects_malformed_fresh_resolver_authority(monkeypatch, monkeypatch.setattr(fixture, "resolve", lambda *args, **kwargs: deepcopy(fresh)) reopened = fixture.restart() with pytest.raises(SourceAuthorityUnverifiedError): - reopened.open_result(task.output("findings")) + reopened.access.authorize_input_sources([source()], require_snapshot=True) + assert len(list(reopened.open_result(task.output("findings")).iter_records())) == 3 -def test_retained_reader_rechecks_outage_then_recovers_without_overwriting_saved_result(monkeypatch): +def test_retained_reader_ignores_a_metadata_outage_that_input_reads_report(monkeypatch): fixture = ResultFixture() task = fixture.save() stored = deepcopy(fixture.container.items) - original_metadata = fixture.metadata - def failed(**kwargs): + def failed(*args, **kwargs): raise CosmosHttpResponseError(status_code=503, message="PRIVATE metadata outage") monkeypatch.setattr(fixture, "metadata", failed) reader_service = fixture.restart() - with pytest.raises(SourceAuthorityUnavailableError): - reader_service.open_result(task.output("findings")) - monkeypatch.setattr(fixture, "metadata", original_metadata) - reader = fixture.restart().open_result(task.output("findings")) - rows = list(reader.iter_records()) + rows = list(reader_service.open_result(task.output("findings")).iter_records()) assert len(rows) == 3 and rows[-1]["id"] == "last" and fixture.container.items == stored + with pytest.raises(SourceAuthorityUnavailableError): + reader_service.access.authorize_input_sources([source()], require_snapshot=True) @pytest.mark.parametrize("first", ["content_screening.access", "functions_orchestration_source_access", "functions_orchestration_results"]) diff --git a/functional_tests/test_orchestration_v2_plan_backend.py b/functional_tests/test_orchestration_v2_plan_backend.py index 10cdb9624..9d0815b7d 100644 --- a/functional_tests/test_orchestration_v2_plan_backend.py +++ b/functional_tests/test_orchestration_v2_plan_backend.py @@ -1,14 +1,15 @@ # test_orchestration_v2_plan_backend.py """Saved plan editing, restoration, and private admission-context persistence. -Version: 0.261.200 +Version: 0.261.232 Implemented in: 0.261.127 Single orchestration contract updated in: 0.261.139 Submission ids kept out of the planner prompt: 0.261.200 -Refs: microsoft/simplechat#1509, microsoft/simplechat#1552 +Retained result aliases take their run's access in: 0.261.232 +Refs: microsoft/simplechat#1509, microsoft/simplechat#1552, microsoft/simplechat#1621 Exercise the real planner/compiler, revision/run stores, immutable result readers, -and current source authorization. Replace only model, Cosmos, and unrelated +and current container authorization. Replace only model, Cosmos, and unrelated catalog/memory I/O boundaries. Block network access. A saved plan from the removed earlier contract is refused, never edited, claimed or replanned. """ @@ -421,7 +422,9 @@ def test_real_validation_preserves_bindings_outputs_and_final_response(self): self.assertEqual(checked['final_response'], original['plan']['final_response']) self.assertEqual(original, before) self.assertEqual(self.resolve_aliases.call_count, 1) - self.assertTrue(self.fixture.source_reads) + # 0.261.232: aliases name retained results, which take their conversation's and run's + # access; their source documents are provenance and are not reread. + self.assertEqual(self.fixture.source_reads, []) def test_legacy_saved_plan_without_top_level_admission_still_uses_its_v2_plan(self): record = self.save() @@ -462,9 +465,20 @@ def test_missing_alias_resolver_fails_closed_without_model_work(self): current = self.read() self.assertEqual(current, record) - def test_revoked_source_access_rejects_edit_validation_and_claim(self): + def test_revoked_source_access_does_not_reject_retained_aliases(self): + # 0.261.232: an alias names a retained result, which takes its conversation's and + # run's access. A step that reads documents checks them as inputs when it runs. record = self.save() self.fixture.denied.add('document-1') + checked = self.validate(record) + self.assertEqual(checked['steps'], record['plan']['steps']) + self.assertEqual(self.fixture.source_reads, []) + self.assertEqual(self.model_calls, []) + self.assertEqual(self.read(), record) + + def test_lost_conversation_rejects_edit_validation_and_claim(self): + record = self.save() + self.fixture.conversation['orchestration_deleted'] = True for operation in ( lambda: self.validate(record), lambda: self.edit(record, self.reply(record)), @@ -1325,10 +1339,11 @@ def test_restore_reauthorizes_source_context_and_preserves_v2_outputs(self): self.assertEqual(restored['revision_origin'], 'restore') self.assertEqual(self.model_calls, []) - def test_restore_refuses_revoked_aliases_without_mutating_the_current_plan(self): + def test_restore_refuses_unavailable_aliases_without_mutating_the_current_plan(self): original = self.hold(self.save()) newer = self.publish(original) - self.fixture.denied.add('document-1') + # 0.261.232: a revoked source no longer hides a retained alias; a lost conversation does. + self.fixture.conversation['orchestration_deleted'] = True with self.assertRaises(self.revisions.PlanRevisionError): self.editor.build_plan_edit_outcome( newer, {'action': 'restore', 'source_run_id': original['id']}, 'owner', self.settings, diff --git a/functional_tests/test_saved_analysis_artifact_access.py b/functional_tests/test_saved_analysis_artifact_access.py index dcbbdd7bb..71306a86b 100644 --- a/functional_tests/test_saved_analysis_artifact_access.py +++ b/functional_tests/test_saved_analysis_artifact_access.py @@ -1,11 +1,13 @@ # test_saved_analysis_artifact_access.py """ Functional tests for Analyze artifact association and publication eligibility. -Version: 0.261.109 +Version: 0.261.232 Implemented in: 0.261.109 +Container-only saved-result access covered in: 0.261.232 -New projections require their exact saved producer and current source access. -Legacy artifacts keep their existing access rules. +New projections require their exact saved producer and their conversation's +access; the documents the analysis read are not checked again. Legacy artifacts +keep their existing access rules. """ from copy import deepcopy @@ -58,9 +60,16 @@ def test_another_analysis_cannot_satisfy_the_artifact_binding(saved_chat): authorize(saved_chat, artifact) -def test_revoked_source_blocks_an_existing_projection(saved_chat): +def test_revoked_source_keeps_an_existing_projection_readable(saved_chat): saved_chat["state"]["source_allowed"] = False - with pytest.raises(access.AnalysisResultUnavailable): + authorize(saved_chat, artifact_for(saved_chat)) + authorize(saved_chat, artifact_for(saved_chat), for_publication=True) + assert saved_chat["state"]["resolutions"] == 0 + + +def test_lost_conversation_blocks_an_existing_projection(saved_chat): + saved_chat["state"]["conversation_allowed"] = False + with pytest.raises(PermissionError): authorize(saved_chat, artifact_for(saved_chat)) diff --git a/functional_tests/test_saved_analysis_diagnostics.py b/functional_tests/test_saved_analysis_diagnostics.py index 0fcdf1d5d..a001e7e92 100644 --- a/functional_tests/test_saved_analysis_diagnostics.py +++ b/functional_tests/test_saved_analysis_diagnostics.py @@ -1,8 +1,9 @@ # test_saved_analysis_diagnostics.py """ -Explicit, bounded, source-authorized saved Analyze diagnostic audit reads. -Version: 0.261.109 +Explicit, bounded, conversation-authorized saved Analyze diagnostic audit reads. +Version: 0.261.232 Implemented in: 0.261.109 +Container-only saved-result access covered in: 0.261.232 Use the real shared Cosmos/Blob store and saved readers with SDK doubles. Diagnostics remain separate from accepted values, reports and model input. @@ -17,7 +18,7 @@ import pytest from test_saved_analysis_store_integration import ( - SOURCE, analysis_result, authorize_run, resolve_sources, save_chat, + analysis_result, authorize_run, resolve_sources, save_chat, save_orchestration, sdk_store, saved, ) from test_workflow_result_contract import ( @@ -120,10 +121,38 @@ def test_unfinished_or_invalid_results_can_be_audited_without_becoming_final_dat saved.load_saved_analysis_input("owner", context, **options) -def test_revocation_blocks_each_diagnostic_read_before_transport(sdk_store): +def test_source_revocation_does_not_block_diagnostic_reads(sdk_store): + # 0.261.232: a saved result takes its access from its conversation; its sources are provenance. context, options = chat_diagnostics(sdk_store) calls = [] - options["source_resolver"] = lambda *args, **kwargs: [{**SOURCE, "authorization_status": "unresolved"}] + + def reread_sources(*args, **kwargs): + raise AssertionError("A saved result must not reread its source documents.") + + options["source_resolver"] = reread_sources + audit = saved.read_saved_analysis_diagnostics( + "owner", context, page_reader=page_reader(sdk_store, calls), **options, + ) + assert audit["audit_only"] is True and len(calls) == 1 + + +def _losable_conversation(options): + state = {"allowed": True} + load_message = options["message_loader"] + + def message_loader(*args, **kwargs): + if not state["allowed"]: + raise PermissionError("Conversation access was removed.") + return load_message(*args, **kwargs) + + options["message_loader"] = message_loader + return state + + +def test_conversation_loss_blocks_each_diagnostic_read_before_transport(sdk_store): + context, options = chat_diagnostics(sdk_store) + calls = [] + _losable_conversation(options)["allowed"] = False with pytest.raises(PermissionError): saved.read_saved_analysis_diagnostics( "owner", context, page_reader=page_reader(sdk_store, calls), **options, @@ -131,12 +160,9 @@ def test_revocation_blocks_each_diagnostic_read_before_transport(sdk_store): assert calls == [] -def test_access_loss_during_transport_returns_no_diagnostic_page(sdk_store): +def test_conversation_loss_during_transport_returns_no_diagnostic_page(sdk_store): context, options = chat_diagnostics(sdk_store) - state = {"allowed": True} - options["source_resolver"] = lambda *args, **kwargs: [{ - **SOURCE, "authorization_status": "authorized" if state["allowed"] else "unresolved", - }] + state = _losable_conversation(options) with pytest.raises(PermissionError): saved.read_saved_analysis_diagnostics( "owner", context, diff --git a/functional_tests/test_saved_analysis_routes.py b/functional_tests/test_saved_analysis_routes.py index d59329cdd..950d4ab1d 100644 --- a/functional_tests/test_saved_analysis_routes.py +++ b/functional_tests/test_saved_analysis_routes.py @@ -1,11 +1,13 @@ # test_saved_analysis_routes.py """ Functional tests for the saved Analyze result reader route. -Version: 0.261.109 +Version: 0.261.232 Implemented in: 0.261.109 +Container-only saved-result access covered in: 0.261.232 The production route runs against serialized saved results, including page -bounds, current source access, evidence selection, and stale references. +bounds, conversation access, evidence selection, and stale references. A saved +result takes its access from its conversation, not from its source documents. """ import ast @@ -80,12 +82,22 @@ def test_second_page_is_available_without_reanalysis(analysis_client): assert response.json["next_offset"] == 50 -def test_source_revocation_returns_no_result_data(analysis_client): +def test_source_revocation_keeps_the_saved_result_readable(analysis_client): client, state, fixture = analysis_client fixture["state"]["source_allowed"] = False response = client.get(result_url(fixture)) + assert response.status_code == 200 + assert response.json["records"] + assert fixture["state"]["resolutions"] == 0 + + +def test_conversation_denial_returns_no_result_data(analysis_client): + client, state, fixture = analysis_client + fixture["state"]["conversation_allowed"] = False + response = client.get(result_url(fixture)) assert response.status_code == 403 assert set(response.json) == {"error"} + assert "source" not in response.json["error"].lower() assert "Control" not in response.get_data(as_text=True) diff --git a/functional_tests/test_saved_analysis_service.py b/functional_tests/test_saved_analysis_service.py index 39fb12773..d75f53493 100644 --- a/functional_tests/test_saved_analysis_service.py +++ b/functional_tests/test_saved_analysis_service.py @@ -1,11 +1,14 @@ # test_saved_analysis_service.py """ Functional tests for saved Analyze data in chat and follow-up reads. -Version: 0.261.109 +Version: 0.261.232 Implemented in: 0.261.109 +Container-only saved-result access covered in: 0.261.232 The production result builder/readers use serialized sections and authorized -message/source seams without re-uploading, indexing, or analyzing source files. +message seams without re-uploading, indexing, or analyzing source files. A saved +result takes its access from its conversation: its source documents are recorded +as provenance and are not checked again when it is saved or read. """ import hashlib @@ -146,15 +149,18 @@ def test_evidence_is_selected_from_the_same_saved_record(saved_chat): assert page["evidence"][0]["quote"] == "Owner: unassigned" -def test_source_revocation_blocks_record_and_evidence_reads(saved_chat): +def test_source_revocation_does_not_hide_record_and_evidence_reads(saved_chat): fixture = saved_chat fixture["state"]["source_allowed"] = False context = saved.saved_analysis_context(fixture["descriptor"]) - for representation in ("records", "evidence"): - with pytest.raises(access.AnalysisResultUnavailable): - saved.read_saved_analysis_page( - "reader", context, representation=representation, record_id="record-0", **read_options(fixture) - ) + records = saved.read_saved_analysis_page("reader", context, **read_options(fixture)) + evidence = saved.read_saved_analysis_page( + "reader", context, representation="evidence", record_id="record-0", **read_options(fixture) + ) + assert records["total_records"] == 60 and records["source_count"] == 1 + assert records["source_snapshot_changed"] is False + assert [item["evidence_id"] for item in evidence["evidence"]] == ["evidence-0"] + assert fixture["state"]["resolutions"] == 0 def test_conversation_denial_happens_before_result_storage_reads(saved_chat): @@ -183,10 +189,26 @@ def test_masked_analysis_cannot_be_recovered_through_record_pages(saved_chat): assert failure.value.code == "analysis_message_masked" -def test_history_withholds_content_and_citations_after_revocation(saved_chat): +def test_history_keeps_content_and_citations_after_source_revocation(saved_chat): fixture = saved_chat fixture["state"]["source_allowed"] = False original = deepcopy(fixture["message"]) + reader = lambda user_id, context: saved.load_saved_analysis(user_id, context, **read_options(fixture)) + messages = saved.sanitize_saved_analysis_messages( + [fixture["message"], {"id": "ordinary", "content": "Unrelated message."}], "reader", result_reader=reader, + ) + assert messages[0]["content"] == original["content"] + assert messages[0]["agent_citations"] == original["agent_citations"] + assert messages[0]["metadata"]["saved_analysis"].get("available") is not False + assert messages[1]["content"] == "Unrelated message." + assert fixture["state"]["resolutions"] == 0 + assert fixture["message"] == original + + +def test_history_withholds_content_and_citations_when_the_conversation_is_lost(saved_chat): + fixture = saved_chat + fixture["state"]["conversation_allowed"] = False + original = deepcopy(fixture["message"]) messages = saved.sanitize_saved_analysis_messages( [fixture["message"], {"id": "ordinary", "content": "Unrelated message."}], "reader", diff --git a/functional_tests/test_saved_analysis_store_integration.py b/functional_tests/test_saved_analysis_store_integration.py index 8c67da05b..ccdc36226 100644 --- a/functional_tests/test_saved_analysis_store_integration.py +++ b/functional_tests/test_saved_analysis_store_integration.py @@ -1,11 +1,13 @@ # test_saved_analysis_store_integration.py """ Functional tests for saved Analyze helpers with the real shared result store. -Version: 0.261.109 +Version: 0.261.232 Implemented in: 0.261.109 +Container-only saved-result access covered in: 0.261.232 -Cosmos/Blob SDK doubles verify pre-message orchestration loading, source access, +Cosmos/Blob SDK doubles verify pre-message orchestration loading, run access, snapshot integrity, and public record/evidence projections without live services. +A saved result takes its access from its run; its sources are not reread. """ from copy import deepcopy @@ -138,7 +140,7 @@ def test_orchestration_identity_cannot_retarget_saved_bytes(sdk_store, field, va ) -def test_orchestration_source_revocation_blocks_pre_message_reuse(sdk_store): +def test_orchestration_source_revocation_does_not_block_pre_message_reuse(sdk_store): descriptor = save_orchestration(sdk_store) reads = [] @@ -146,12 +148,28 @@ def load(*args): reads.append(args[-1]["sha256"]) return sdk_store.load_orchestration(*args) + text, consumed = saved.load_orchestration_analysis_input( + "owner", descriptor, authorize_run=authorize_run, load_result=load, + source_resolver=lambda *args, **kwargs: pytest.fail("A saved result must not reread its sources."), + ) + payload = json.loads(text) + assert len(payload["records"]) == 121 + assert payload["source_snapshot_changed"] is False + assert consumed == descriptor + assert reads[0] == descriptor["result_sha256"] + + +def test_orchestration_run_loss_still_blocks_pre_message_reuse(sdk_store): + descriptor = save_orchestration(sdk_store) + + def lost_run(*args): + raise PermissionError("The run is no longer available.") + with pytest.raises(PermissionError): saved.load_orchestration_analysis_input( - "owner", descriptor, authorize_run=authorize_run, load_result=load, - source_resolver=lambda *args, **kwargs: [{**SOURCE, "authorization_status": "unresolved"}], + "owner", descriptor, authorize_run=lost_run, + load_result=lambda *args: pytest.fail("Unauthorized result read."), ) - assert reads == [descriptor["result_sha256"]] def test_evidence_projection_preserves_the_canonical_nested_evidence(sdk_store): diff --git a/functional_tests/test_saved_results_container_access_chat_orchestration.py b/functional_tests/test_saved_results_container_access_chat_orchestration.py new file mode 100644 index 000000000..98129a844 --- /dev/null +++ b/functional_tests/test_saved_results_container_access_chat_orchestration.py @@ -0,0 +1,564 @@ +# test_saved_results_container_access_chat_orchestration.py +""" +Functional test for upload-only content screening in chat, Analyze and orchestration. +Version: 0.261.232 +Implemented in: 0.261.232 + +This test ensures that saved and generated results take their access from their +container (the conversation or orchestration run) and are never hidden because a +source document was later deleted, re-uploaded or held for screening: saved Analyze +results, retained orchestration outputs, generated files, chat AI replies with their +stored citations, and conversation exports. It also ensures that a held uploaded +document is still refused whenever chat, search or an orchestration reads it as an +input, that the model fence still blocks a model call after such a read, and that +lineage and integrity failures are still refused. Refs #1621. + +Cosmos, Blob, search and model I/O are doubled. No Azure resources are used. +""" + +import ast +from collections import Counter, defaultdict +from collections.abc import Mapping +from copy import deepcopy +import hashlib +import json +from pathlib import Path +import sys +from typing import Any, Dict, List +from unittest.mock import patch + +import pytest +from flask import jsonify + +TESTS_ROOT = Path(__file__).resolve().parent +APP_ROOT = TESTS_ROOT.parent / "application" / "single_app" +for _path in (str(APP_ROOT), str(TESTS_ROOT)): + if _path not in sys.path: + sys.path.insert(0, _path) + +# Application and fixture imports follow the standalone path setup. +from content_screening import access as screening_access # noqa: E402 +from content_screening.contracts import DocumentHeldError, ScreeningError # noqa: E402 +from functions_orchestration_result_contracts import ResultContractError # noqa: E402 +from functions_orchestration_results import ResultUnavailableError # noqa: E402 +from functions_workflow_result_store import WorkflowResultIntegrityError # noqa: E402 +from test_content_screening_access import FakeContainer, MissingDocument, ScreeningAccessFixture, fake_module # noqa: E402 +from test_support.app_stubs import import_app_module # noqa: E402 +from test_support.orchestration_results import ROWS, ResultFixture, source # noqa: E402 +from test_support.versioning import assert_app_version_at_least # noqa: E402 + +saved = import_app_module("functions_saved_analysis") +analysis_access = import_app_module("functions_analysis_access") + +SOURCE_CHANGES = ("deleted", "reuploaded", "held") +SAVED_SOURCE = { + "document_id": "document-1", "scope": "group", "scope_id": "source-group", + "source_version": "1", "source_revision": "etag-1", "authorization_status": "authorized", +} +REPLY = "The review found controls that still need an owner." + + +def load_body(file_name, function_name, namespace): + """Execute one real application function against the supplied seams.""" + tree = ast.parse((APP_ROOT / file_name).read_text(encoding="utf-8")) + node = next( + node for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name == function_name + ) + exec(compile(ast.Module(body=[node], type_ignores=[]), str(APP_ROOT / file_name), "exec"), namespace) + return namespace[function_name] + + +def test_current_version_includes_container_access(): + assert_app_version_at_least("0.261.232") + + +# --- Saved Analyze results -------------------------------------------------- + + +class SourceState: + """A source document that may be deleted, re-uploaded or held when it is resolved.""" + + def __init__(self): + self.change = None + self.resolutions = 0 + + def resolve(self, document_ids, **context): + self.resolutions += 1 + if self.change == "held": + raise DocumentHeldError() + if self.change == "deleted": + return [{**SAVED_SOURCE, "authorization_status": "unresolved"}] + if self.change == "reuploaded": + return [{**SAVED_SOURCE, "source_version": "2", "source_revision": "etag-2"}] + return [dict(SAVED_SOURCE)] + + +class ChatSections: + def __init__(self): + self.contents = {} + + def save(self, user_id, conversation_id, message_id, section, **kwargs): + payload = json.dumps(section, ensure_ascii=True, sort_keys=True, separators=(",", ":")) + digest = hashlib.sha256(payload.encode("ascii")).hexdigest() + self.contents[(user_id, conversation_id, message_id, digest)] = payload + return { + "storage": "cosmos", "schema_version": 1, "sha256": digest, + "size_bytes": len(payload), "chunk_count": 1, + } + + def load(self, user_id, conversation_id, message_id, reference): + return json.loads(self.contents[(user_id, conversation_id, message_id, reference["sha256"])]) + + def rewrite(self, reference, change): + key = next(key for key in self.contents if key[3] == reference["sha256"]) + value = json.loads(self.contents[key]) + change(value) + self.contents[key] = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")) + + +class SavedChat: + """A saved chat Analyze result, its assistant message and a generated projection.""" + + def __init__(self, sources): + self.sources = sources + self.conversation_allowed = True + self.store = ChatSections() + result = { + "reply": REPLY, + "analysis_result": { + "analysis_result_version": "analyze-final-v1", + "analysis_reply": f"## Findings\n\n{REPLY}", + "source_manifest": [dict(SAVED_SOURCE)], + "authoritative_result": { + "kind": "records", + "value": [{ + "record_id": f"record-{index}", "document_id": "document-1", + "source": {"file_name": "controls.txt"}, + "values": {"control": f"Control {index}", "finding": "Owner unassigned"}, + "evidence_refs": [f"evidence-{index}"], + } for index in range(3)], + }, + "analysis_evidence": [{ + "evidence_id": f"evidence-{index}", "document_id": "document-1", + "file_name": "controls.txt", "page_number": index + 1, "quote": "Owner: unassigned", + } for index in range(3)], + "analysis_validation": {"status": "valid", "limitations": []}, + }, + } + self.descriptor = saved.save_chat_analysis( + result, user_id="owner", conversation_id="conversation-1", message_id="assistant-1", + authorize_conversation=self.authorize, save_result=self.store.save, + source_resolver=sources.resolve, + ) + self.message = { + "id": "assistant-1", "conversation_id": "conversation-1", "role": "assistant", + "content": REPLY, "metadata": {"saved_analysis": self.descriptor}, + "hybrid_citations": [{"document_id": "document-1", "chunk_text": "Owner: unassigned"}], + "agent_citations": [{"function_result": "STORED_TOOL_CITATION"}], + } + self.context = saved.saved_analysis_context(self.descriptor) + + def authorize(self, user_id, conversation_id): + if user_id not in ("owner", "reader") or conversation_id != "conversation-1" or not self.conversation_allowed: + raise PermissionError("Conversation access was removed.") + return {"id": conversation_id, "user_id": "owner"} + + def load_message(self, user_id, conversation_id, message_id): + self.authorize(user_id, conversation_id) + if message_id != "assistant-1": + raise LookupError("Message not found.") + return self.message + + def read_options(self): + return { + "message_loader": self.load_message, "chat_loader": self.store.load, + "source_resolver": self.sources.resolve, + } + + def read(self, context=None): + return saved.load_saved_analysis("reader", context or self.context, **self.read_options()) + + def artifact(self): + return { + "id": "artifact-1", "conversation_id": "conversation-1", "role": "file", + "metadata": saved.analysis_artifact_metadata({ + "kind": "chat", "conversation_id": "conversation-1", "message_id": "assistant-1", + }), + } + + def authorize_artifact(self, **kwargs): + return saved.authorize_analysis_artifact( + "reader", self.artifact(), parents_loader=lambda *args: [self.message], + result_reader=lambda user_id, context: self.read(context), **kwargs, + ) + + +@pytest.mark.parametrize("when", ["before_save", "after_save"]) +@pytest.mark.parametrize("change", SOURCE_CHANGES) +def test_saved_analyze_results_keep_conversation_access_when_a_source_changes(change, when): + sources = SourceState() + if when == "before_save": + sources.change = change + chat = SavedChat(sources) + sources.change = change + original = deepcopy(chat.message) + + manifest, _, checked = chat.read() + page = saved.read_saved_analysis_page("reader", chat.context, **chat.read_options()) + evidence = saved.read_saved_analysis_page( + "reader", chat.context, representation="evidence", record_id="record-1", **chat.read_options(), + ) + explanation, descriptor = saved.load_saved_analysis_input("reader", chat.context, **chat.read_options()) + history = saved.sanitize_saved_analysis_messages( + [chat.message], "reader", result_reader=lambda user_id, context: chat.read(context), + ) + chat.authorize_artifact() + chat.authorize_artifact(for_publication=True) + + assert manifest["identity"]["message_id"] == "assistant-1" + assert checked["source_count"] == 1 and checked["source_snapshot_changed"] is False + assert page["total_records"] == 3 + assert [item["evidence_id"] for item in evidence["evidence"]] == ["evidence-1"] + assert '"original_sources_reanalyzed": false' in explanation and "Control 2" in explanation + assert descriptor["result_sha256"] == chat.descriptor["result_sha256"] + assert history[0]["content"] == REPLY + assert history[0]["hybrid_citations"] == original["hybrid_citations"] + assert history[0]["agent_citations"] == original["agent_citations"] + assert history[0]["metadata"]["saved_analysis"].get("available") is not False + assert sources.resolutions == 0 + + +def test_saved_analyze_results_still_refuse_container_and_integrity_failures(): + sources = SourceState() + chat = SavedChat(sources) + + with pytest.raises(ValueError, match="changed"): + chat.read({**chat.context, "result_sha256": "0" * 64}) + + chat.store.rewrite(chat.descriptor["result_ref"], lambda manifest: manifest["identity"].update(message_id="other")) + with pytest.raises(analysis_access.AnalysisResultUnavailable) as lineage: + chat.read() + assert lineage.value.code == "analysis_lineage_invalid" + + chat.store.rewrite(chat.descriptor["result_ref"], lambda manifest: manifest["identity"].update(message_id="assistant-1")) + chat.store.rewrite(chat.descriptor["result_ref"], lambda manifest: manifest["analysis_access"].update(sources=[])) + with pytest.raises(analysis_access.AnalysisResultUnavailable) as provenance: + chat.read() + assert provenance.value.code == "analysis_source_manifest_missing" + + chat.conversation_allowed = False + with pytest.raises(PermissionError): + chat.read() + with pytest.raises(PermissionError): + chat.authorize_artifact() + history = saved.sanitize_saved_analysis_messages( + [chat.message], "reader", result_reader=lambda user_id, context: chat.read(context), + ) + assert history[0]["content"] == saved.UNAVAILABLE_ANALYSIS_MESSAGE + assert history[0]["agent_citations"] == [] and "STORED_TOOL_CITATION" not in json.dumps(history) + assert sources.resolutions == 0 + + +# --- Retained orchestration outputs ---------------------------------------------- + + +def _change_orchestration_source(fixture, change): + if change == "deleted": + fixture.sources.clear() + elif change == "reuploaded": + fixture.sources["document-1"]["source_version"] = 2 + fixture.sources["document-1"]["source_revision"] = "revision-2" + else: + fixture.held.add("document-1") + + +@pytest.mark.parametrize("when", ["before_save", "after_save"]) +@pytest.mark.parametrize("change", SOURCE_CHANGES) +def test_orchestration_outputs_keep_run_access_when_a_source_changes(change, when): + fixture = ResultFixture() + if when == "before_save": + _change_orchestration_source(fixture, change) + task = fixture.save() + if when == "after_save": + _change_orchestration_source(fixture, change) + reader = fixture.restart().open_result(task.output("findings"), require_current_sources=True) + rows = list(reader.iter_records()) + reader.recheck() + assert rows == ROWS + assert reader.metadata()["source_count"] == 1 + assert fixture.source_reads == [] + + +def test_orchestration_outputs_still_refuse_container_and_integrity_failures(): + fixture = ResultFixture() + reference = fixture.save().output("findings") + fixture.held.add("document-1") + store = fixture.service.store + producer = reference.producer + arguments = (producer.user_id, producer.conversation_id, producer.run_id, producer.step_id) + uncommitted = store.save_orchestration( + *arguments, {"private": "uncommitted"}, guard_token="server-attempt-token", require_analysis_guard=True, + ) + with pytest.raises(WorkflowResultIntegrityError): + fixture.service.open_result(reference.__class__.from_dict({ + **reference.to_dict(), "manifest_sha256": uncommitted["sha256"], + })) + corrupt = reference.__class__.from_dict({**reference.to_dict(), "content_sha256": "0" * 64}) + with pytest.raises(ResultContractError): + fixture.service.open_result(corrupt) + + fixture.conversation["orchestration_deleted"] = True + with pytest.raises(ResultUnavailableError): + fixture.restart().open_result(reference) + fixture.conversation.pop("orchestration_deleted") + fixture.runs["run-1"]["checkpoints_deleted"] = True + with pytest.raises(ResultUnavailableError): + fixture.restart().open_result(reference) + assert fixture.source_reads == [] + + +@pytest.mark.parametrize("change", ["deleted", "held"]) +def test_orchestration_still_checks_uploaded_documents_it_reads_as_inputs(change): + fixture = ResultFixture() + _change_orchestration_source(fixture, change) + with pytest.raises((ScreeningError, PermissionError, LookupError)): + fixture.service.access.authorize_input_sources([source()], require_snapshot=True) + assert fixture.source_reads + + +# --- Chat history, exports, generated files and input reads ------------------------ + + +class ContainerAccessAndInputReadTests(ScreeningAccessFixture): + def setUp(self): + super().setUp() + self.telemetry = patch.dict(sys.modules, { + "functions_appinsights": fake_module( + "functions_appinsights", log_event=lambda *args, **kwargs: None, + debug_print=lambda *args, **kwargs: None, + ), + }) + + def change_source(self, change): + if change == "held": + self.hold() + elif change == "deleted": + self.documents.pop("document-1") + else: + self.document["version"] = 3 + self.document["content_screening"]["source_revision"] = "3" + self.seed_release(self.document) + + def provenance(self): + return {screening_access.PROVENANCE_FIELD: screening_access.document_provenance(self.document)} + + def assistant_reply(self): + return { + "id": "assistant-message", "conversation_id": "conversation-1", "role": "assistant", + "content": REPLY, + "hybrid_citations": [{ + "document_id": "document-1", "version": 2, "file_name": "reviewed.txt", + "chunk_text": "STORED_DOCUMENT_CITATION", + }], + "agent_citations": [{ + "plugin_name": "BlobStoragePlugin", "function_name": "read_file_content", "success": True, + "function_arguments": {"blob_name": self.document["content_screening"]["active_blob"]["path"]}, + "function_result": { + "container_name": "user-documents", + "blob_name": self.document["content_screening"]["active_blob"]["path"], + "content": "STORED_TOOL_CITATION", + }, + }], + "metadata": {"screening_sources": [self.provenance()]}, + } + + def workspace_attachment(self): + return { + "id": "file-message", "conversation_id": "conversation-1", "role": "file", + "workspace_document_id": "document-1", + "file_content": "ATTACHMENT_TEXT", "extracted_text": "ATTACHMENT_TEXT", + } + + def generated_artifact(self, **changes): + return { + "id": "artifact-1", "conversation_id": "conversation-1", "role": "file", + "file_content_source": "blob", "blob_container": "personal-chat", + "blob_path": "user-1/conversation-1/generated/artifact-1/report.md", "filename": "report.md", + "metadata": {"is_generated_chat_artifact": True, "screening_sources": [self.provenance()]}, + **changes, + } + + def chat_artifact_authorizer(self, message): + return load_body("route_enhanced_citations.py", "_get_authorized_chat_artifact_message", { + "cosmos_conversations_container": self.conversations, + "cosmos_messages_container": FakeContainer({message["id"]: message}), + "CosmosResourceNotFoundError": MissingDocument, + "build_conversation_participation_context": self.authorize_conversation, + "assert_generated_file_approval_allows_download": lambda user_id, item: None, + "assert_generated_chat_artifact_is_published_for_user": lambda user_id, item: None, + "assert_document_available": screening_access.assert_document_available, + "assert_evidence_available": screening_access.assert_evidence_available, + }) + + def export(self, messages): + namespace = { + "Any": Any, "Dict": Dict, "List": List, "Mapping": Mapping, + "Counter": Counter, "defaultdict": defaultdict, + "TRANSCRIPT_ROLES": {"user", "assistant"}, + "build_message_artifact_payload_map": lambda raw_messages: {}, + "_filter_messages_for_export": list, + "sanitize_saved_analysis_messages": saved.sanitize_saved_analysis_messages, + "hydrate_agent_citations_from_artifacts": lambda raw_messages, payloads: raw_messages, + "public_history_messages": screening_access.public_history_messages, + "sort_messages_by_thread": list, + "is_collaboration_conversation": lambda conversation: False, + "get_thoughts_for_conversation": lambda *args: [], + "get_accessible_collaboration_message_thoughts": lambda *args: [], + "_sanitize_thought": deepcopy, + "_sanitize_message": lambda message, **kwargs: deepcopy(message), + "_sanitize_conversation": lambda conversation, **kwargs: conversation, + "_build_summary_intro": lambda **kwargs: None, + } + load_body("functions_saved_analysis.py", "is_saved_analysis_unavailable", namespace) + build = load_body("route_backend_conversation_export.py", "_build_export_entry", namespace) + return build({"id": "conversation-1"}, messages, "user-1", {})["messages"] + + def test_chat_replies_citations_and_exports_survive_source_changes(self): + for change in SOURCE_CHANGES: + with self.subTest(change=change): + self.setUp() + self.modules["functions_documents"].get_ordered_document_chunks = ( + lambda *args, **kwargs: [self.search_result(version=self.document["version"])] + ) + reply = self.assistant_reply() + attachment = self.workspace_attachment() + self.change_source(change) + reads_before = self.personal.reads["document-1"] + history = screening_access.public_history_messages([deepcopy(reply)], "user-1") + self.assertEqual(self.personal.reads["document-1"], reads_before) + exported = {message["id"]: message for message in self.export([deepcopy(reply), attachment])} + for message in (history[0], exported["assistant-message"]): + self.assertEqual(message["content"], REPLY) + self.assertNotIn("content_unavailable", message) + self.assertEqual(message["hybrid_citations"], reply["hybrid_citations"]) + self.assertIn("STORED_TOOL_CITATION", json.dumps(message["agent_citations"])) + file_message = exported["file-message"] + if change == "reuploaded": + self.assertEqual(file_message["file_content"], "Clean approved text") + else: + self.assertTrue(file_message["content_unavailable"]) + self.assertNotIn("ATTACHMENT_TEXT", json.dumps(file_message)) + + def test_model_history_replays_stored_replies_without_rechecking_their_sources(self): + evidence_checks = [] + helper = load_body("route_backend_chats.py", "build_assistant_history_content_with_citations", { + "assert_evidence_available": lambda *args, **kwargs: evidence_checks.append(args), + "_build_agent_citation_history_lines": lambda citations: [json.dumps(citations)], + "_build_document_citation_history_lines": lambda citations: [json.dumps(citations)], + "_build_web_citation_history_lines": lambda citations: [], + "_truncate_history_citation_text": lambda text, max_chars: text, + }) + self.hold() + content = helper(self.assistant_reply(), REPLY) + self.assertIn(REPLY, content) + self.assertIn("STORED_TOOL_CITATION", content) + self.assertEqual(evidence_checks, []) + self.assertEqual(self.personal.reads["document-1"], 0) + + def test_generated_chat_files_take_conversation_access_not_source_access(self): + for change in SOURCE_CHANGES: + with self.subTest(change=change): + self.setUp() + artifact = self.generated_artifact() + self.change_source(change) + self.assertEqual(self.chat_artifact_authorizer(artifact)("user-1", "conversation-1", "artifact-1")["id"], "artifact-1") + self.setUp() + with self.assertRaises(PermissionError): + self.chat_artifact_authorizer(self.generated_artifact())("user-2", "conversation-1", "artifact-1") + with self.assertRaises(LookupError): + self.chat_artifact_authorizer(self.generated_artifact(file_content_source="workspace"))( + "user-1", "conversation-1", "artifact-1", + ) + + def test_held_upload_is_still_refused_when_read_as_an_input(self): + self.modules["functions_documents"].get_chat_upload_workspace_documents_for_conversation = ( + lambda user_id, conversation_id: [deepcopy(self.document)] + ) + namespace = { + "DOCUMENT_ACTION_TYPE_NONE": "none", + "debug_print": lambda *args, **kwargs: None, + "_assigned_knowledge_allows_document_action": lambda filters, action: True, + "assert_document_available": screening_access.assert_document_available, + "ScreeningError": ScreeningError, + "_is_search_ready_chat_upload_workspace_document": lambda document: True, + "_get_chat_upload_workspace_document_scope": lambda document: "personal", + } + load_body("route_backend_chats.py", "_normalize_conversation_task_document_ids", namespace) + select_documents = load_body("route_backend_chats.py", "_resolve_conversation_task_documents", namespace) + open_citation = load_body("route_enhanced_citations.py", "get_document", { + "get_settings": lambda: {"enable_user_workspace": True}, + "get_user_groups": lambda user_id: [], + "get_user_visible_public_workspace_ids_from_settings": lambda user_id: [], + "get_document_record": lambda user_id, doc_id, **scope: ( + deepcopy(self.documents[doc_id]) if doc_id in self.documents else None + ), + "assert_document_available": screening_access.assert_document_available, + "ScreeningError": ScreeningError, "jsonify": jsonify, + }) + with self.app.test_request_context(): + self.assertEqual(open_citation("user-1", "document-1")[1], 200) + self.assertEqual( + select_documents(user_id="user-1", conversation_id="conversation-1")["document_ids"], ["document-1"], + ) + self.assertEqual(len(screening_access.filter_available_results([self.search_result()], "user-1")), 1) + + self.hold() + self.assertEqual(screening_access.filter_available_results([self.search_result()], "user-1"), []) + selected = select_documents(user_id="user-1", conversation_id="conversation-1") + self.assertEqual((selected["document_ids"], selected["pending_document_ids"]), ([], ["document-1"])) + with self.assertRaises(DocumentHeldError): + select_documents(user_id="user-1", conversation_id="conversation-1", candidate_document_ids=["document-1"]) + with self.app.test_request_context(): + response, status = open_citation("user-1", "document-1") + self.assertNotEqual(status, 200) + self.assertEqual(response.get_json()["error_code"], DocumentHeldError.code) + with self.assertRaises(DocumentHeldError): + screening_access.read_available_document_bytes("document-1", "user-1", purpose="preview") + with self.assertRaises(DocumentHeldError): + self.chat_artifact_authorizer(self.generated_artifact(workspace_document_id="document-1"))( + "user-1", "conversation-1", "artifact-1", + ) + history = screening_access.public_history_messages([self.workspace_attachment()], "user-1") + self.assertTrue(history[0]["content_unavailable"]) + self.assertNotIn("ATTACHMENT_TEXT", json.dumps(history)) + + def test_only_a_held_input_read_closes_the_model_fence(self): + self.hold() + calls = [] + with self.app.test_request_context(): + history = screening_access.public_history_messages([self.assistant_reply()], "user-1") + self.assertEqual(history[0]["content"], REPLY) + answer = screening_access.guard_model_callable(lambda: calls.append("replayed") or "ok", [], "user-1") + self.assertEqual(answer(), "ok") + with self.assertRaises(DocumentHeldError): + screening_access.assert_document_available("document-1", "user-1", purpose="chat_file") + blocked = screening_access.guard_model_callable(lambda: calls.append("after_input"), [], "user-1") + with self.assertRaises(DocumentHeldError): + blocked() + self.assertEqual(calls, ["replayed"]) + + def test_a_caught_held_input_read_still_blocks_a_headless_model_call(self): + self.hold() + calls = [] + with self.telemetry, self.assertRaises(DocumentHeldError): + with screening_access.strict_source_authority(): + # A headless step may catch the refused input read and carry on; the fence still holds. + with self.assertRaises(DocumentHeldError): + screening_access.assert_document_available("document-1", "user-1", purpose="orchestration") + screening_access.guard_model_callable(lambda: calls.append("model"), [], "user-1")() + self.assertEqual(calls, []) + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-q"])) diff --git a/ui_tests/test_v2_orchestration_outputs.py b/ui_tests/test_v2_orchestration_outputs.py index 2e1904843..204064631 100644 --- a/ui_tests/test_v2_orchestration_outputs.py +++ b/ui_tests/test_v2_orchestration_outputs.py @@ -1,10 +1,11 @@ # test_v2_orchestration_outputs.py """ Real-component coverage for independent orchestration file recovery. -Version: 0.261.141 +Version: 0.261.232 Implemented in: 0.261.127 Simplified file cards covered in: 0.261.141 -Refs: microsoft/simplechat#1509 +Container-only file access messages covered in: 0.261.232 +Refs: microsoft/simplechat#1509, microsoft/simplechat#1621 Executes the production React thread/drawer, stores, SSE reader and HTTP clients with the existing local/Azure Playwright fixture and production CSS. Responses @@ -866,8 +867,8 @@ def test_auth_or_conflict_rejection_is_safe_and_does_not_hide_ready_sibling(outp @pytest.mark.parametrize("reason,message", [ - ("output_access_denied", "This file is unavailable because current source access could not be confirmed."), - ("output_screening_hold", "This file is unavailable while its source is under review."), + ("output_access_denied", "This file is unavailable because access to its conversation could not be confirmed."), + ("output_artifact_missing", "This file is unavailable because its committed artifact is missing."), ("output_deleted", "This file was deleted."), ]) @pytest.mark.parametrize("width", [1440, 390]) @@ -921,6 +922,33 @@ def test_authoritative_unavailability_withholds_stale_card_but_preserves_ready_s assert not write_calls(api) +@pytest.mark.parametrize("width", [1440, 390]) +def test_uncoded_unavailable_file_names_access_not_its_sources(outputs_ui, width): + page, api = outputs_ui + page.set_viewport_size({"width": width, "height": 900}) + api.record["outputs"][1] = public_output(RETRY_ID, "completed", "summary.csv", "file_b") + api.publish() + files = mount_outputs(page, api) + expect(files.get_by_role("button", name="Download CSV")).to_have_count(2) + api.output.update( + available=False, artifact_message_id=None, can_retry=False, next_retry_at=None, + row_count=None, character_count=None, size_bytes=None, error_code=None, + ) + api.publish() + files.get_by_role("button", name="Check saved file status", exact=True).click() + card = file_card(files) + expect(card.get_by_role("status")).to_have_text("Unavailable") + expect(card).to_contain_text( + "This file is unavailable because current access could not be confirmed. " + "Other ready files remain available." + ) + expect(card).not_to_contain_text("source access") + expect(card).not_to_contain_text("screening") + expect(card.get_by_role("button", name="Download CSV")).to_have_count(0) + expect(file_card(files, "findings.csv").get_by_role("button", name="Download CSV")).to_be_enabled() + assert not write_calls(api) + + @pytest.mark.parametrize("mode", ["server_error", "network"]) @pytest.mark.parametrize("width", [1440, 390]) def test_status_read_failure_preserves_progress_without_inventing_source_denial(outputs_ui, mode, width):