Skip to content

Latest commit

 

History

History
539 lines (471 loc) · 33.5 KB

14-Python_API_Surfaces.md

File metadata and controls

539 lines (471 loc) · 33.5 KB

Python API Surfaces

Normative public Python surfaces for embedding Weft. Operation meaning is owned by the vertical specs; this document owns which names are public, the stability policy, result/error contracts, and surface layering.

Public surfaces [PY-1]

Surface Import form Role
weft.client package (__all__) Primary embedder interface for application logic.
weft.ext module (__all__) Extension and downstream contracts.
weft.commands package (__all__, lazy facade) CLI-equivalent adapter for process and CLI reuse.

Each __all__ is its authoritative public-name inventory. Names not exported there, including weft.core.*, helpers, constants, command leaves, and execute_run, are private.

weft.ext.__all__ exports RunnerHandle, RunnerCapabilities, RunnerRuntimeDescription, AgentResolverResult, AgentToolProfileResult, AgentMCPServerDescriptor, RunnerEnvironmentProfileResult, AgentResolver, AgentToolProfile, RunnerEnvironmentProfile, TaskRunnerBackend, RunnerPlugin, SpecRunInputRequest, ResourceMetrics, RunnerOutcome, SessionExecutionResult, NormalizedAgentMessage, NormalizedAgentWorkItem, CommandSessionProtocol, and AgentSessionProtocol.

weft.client.__all__ retains its existing inventory and adds exactly CommandError, CommandUsageError, CommandTimeoutError, CommandExecutionError, SubmissionError, SubmissionValidationError, SubmissionManagerError, and normalize_taskspec_payload.

The client additionally exports TaskSpec, SpecSection, IOSection, StateSection, LimitsSection, RunnerSection, ReservedPolicy, AgentSection, AgentTemplateSection, AgentToolSection, ParameterizationSection, ParameterizationArgumentSection, RunInputSection, RunInputArgumentSection, RunInputStdinSection, WeftContext, and build_context. These are the existing validated models and context owner, not parallel representations. Supported TaskSpec use is construction, validation, field inspection, and serialization. Lifecycle and transport helpers remain runtime internals. Template validation uses TaskSpec.model_validate(payload, context={"template": True, "auto_expand": False}); normal construction retains resolved expansion. Submission remains responsible for committing the task and its TID.

Ordinary WeftClient submission uses weft.spawn.requests. Same-context runtime identity supplies system-owned root, submitter and finite ancestry without a future child count. Caller metadata cannot grant ancestry or internal authority. Outside that runtime or context, submissions are roots. Client sessions do not change result semantics or task limits. [MA-1.8] states the finite independent-subtree progress conditions; arbitrary cross-task waits have no such guarantee.

build_context retains its existing arguments and side effects: by default it resolves configuration and creates the metadata directories and broker; create_dirs=False and create_database=False disable those respective creation steps; resolution can still create the project root. A supplied context is preserved by WeftClient. The context does not own a permanently open broker; callers close queues they obtain and use the broker context manager to release its resources. An explicitly entered WeftClient may own one lazy BrokerSession lease for its exact resolved context and Config identity. Submission operations borrow that lease but keep each connection checkout and transaction bounded. An unentered or closed client retains bounded per-call session ownership. The context's config and broker_config are immutable SimpleBroker Config snapshots with uppercase unprefixed keys, as specified in [SB-0.4]. Create a new context to apply changed configuration; copying its values does not reconfigure live handles. Broker types remain SimpleBroker's public contracts.

WeftContext.session() returns a new SimpleBroker BrokerSession bound to the context's resolved target and Config. It is intended for a with block entered and exited by the executing thread. WeftContext does not cache a live session. WeftClient.__enter__() establishes same-process, same-thread ownership but creates the retained session lazily on first same-context submission. close() releases that lease on the owning thread. Failed close retains cleanup ownership and blocks submission or re-entry until the same owner retries successfully. Sequential re-entry is allowed; active nesting and active-scope submission, re-entry, and close from a foreign thread are rejected. Preparation and observation retain their existing ownership contracts. A forked child first abandons the inherited handle through SimpleBroker's public child-safe BrokerSession.close() path, then uses child-owned bounded state unless explicitly re-entered in the child. Successful close is idempotent and returns the client to bounded behavior. Already returned Task and PreparedSubmission handles remain usable after close.

WeftClient.__exit__() preserves an active body exception over ordinary close failure by attaching cleanup diagnostics; with no body exception the close failure propagates. Non-ordinary cleanup exceptions retain SimpleBroker's priority. Retention spans the session lease and its single lazy spawn-queue facade. It never spans a queue operation, backend checkout, SQL transaction, manager recovery, or readiness wait. Existing queue() and broker() ownership and defaults are unchanged.

Implementation: weft/client/_client.py::WeftClient and weft/commands/submission.py::_submit_with_session. Related plan: Burst result observation.

Implementation mapping: weft/context.py::WeftContext.session owns the factory. weft/client/_client.py::WeftClient owns the retained client lifecycle. Exact target/config behavior is covered by tests/context/test_context.py; client ownership is covered by tests/core/test_client.py and tests/core/test_public_client_contracts.py; real-backend task/session cleanup is covered by tests/core/test_task_runtime_connections.py.

Related plan: 2026-09-14-simplebroker-8-2-configuration-plan (retired — source 396c3b85; see docs/plans/README.md). Related plan: Explicit broker session lifetimes.

Extension value types preserve their existing fields and behavior. CommandSessionProtocol exposes readonly pid, handle, last_metrics; send(data), close_stdin(), poll_stdout(), poll_stderr(), is_alive(), returncode(), terminate(*, deadline=None), close(), poll_limits(), and stop_monitor(). AgentSessionProtocol exposes readonly pid, handle, execute(work_item, *, cancel_requested=None) -> SessionExecutionResult, and close(*, deadline=None). Backend session methods return these structural protocols; concrete process session constructors remain private. These types preserve existing lifecycle semantics and introduce no new cleanup guarantee. Agent callbacks receive the AgentSection publicly available from weft.client and normalized work-item types from weft.ext. First-party runners may still use private implementation under coordinated versioning; these exports do not promise a complete standalone backend SDK.

TaskRunnerBackend.start_session(*, on_activity) requires a zero-argument callback and returns CommandSessionProtocol. An interactive backend publishes session-observable stream or tracked-runtime-exit state before invoking the callback. TaskRunner.start_session() forwards it unchanged. CommandSessionProtocol gains no notification registration or wait method.

Implementation: weft/client/__init__.py re-exports the schema from weft/core/taskspec/model.py and context from weft/context.py. weft/ext.py owns the public extension values and protocols; weft/core/tasks/runner.py, weft/core/tasks/consumer.py, and weft/core/tasks/interactive.py consume the structural session contracts.

WeftClient.from_context(spec_context=None, *, fallback_root=None, autostart=None) requests a resolved context from the core context owner. fallback_root follows [SB-0.4] and is not an explicit override of a parameterized TaskSpec reference's declared context. An explicit path argument, caller-supplied WeftContext, or resolved Config CONTEXT is explicit. Direct WeftClient() construction and the factory agree about this policy. Clients preserve supplied contexts and use their resolved Config snapshots; the Django adapter does not set core's context-precedence bookkeeping.

Implementation mapping: weft/client/_client.py::WeftClient.__init__, weft/client/_client.py::WeftClient.from_context, weft/context.py::build_context, weft/client/__init__.py, weft/commands/__init__.py, weft/ext.py, weft/core/taskspec/model.py, tests/core/test_client.py, tests/core/test_public_client_contracts.py, tests/core/test_public_extension_contracts.py, and tests/fixtures/public_extension_contract.py.

Commands surface contract [PY-2]

Task observations use task-owned per-TID snapshots and bound observation configuration under [MF-5]. Waiting observers register exact task-state interests before their read/check/wait cycle. Pure point queries use a broker read without an activity subscription. System status, task list, and stats default to current nonterminal inventory; include_terminal=True adds retained terminal projections. include_log_history=True explicitly unions retained raw history. System status exposes include_terminal=False, status=None, and include_log_history=False; task list/stats retain their existing selection arguments and add include_log_history=False. Bulk inventory may lag completion after failed publication or abrupt child death; known-TID local evidence keeps its authority. Ordinary observation never queries Monitor tables. Missing configuration follows [MF-5]'s unknown/finite-timeout contract; unresolved controls fail before writing. Accepted handles retain their immutable configuration after metadata retirement. Explicit event streams provide retained/live raw events without lossless history.

Every canonical CLI verb has one actual implementation exported from weft.commands: cmd_ plus its full CLI path, joined with underscores and hyphens normalized to underscores. The initial bijection is 41 verbs: 4 root, 14 queue, 6 spec, 6 task, 5 manager, and 6 system. The root callback and global --version are parser features, not verbs. The package resolves exports lazily and caches them.

Commands accept parsed semantic values, perform semantic validation and orchestration, return structured outcomes, and raise typed errors. They do not read process stdin or write process output. weft.cli owns shell decoding, human/JSON formatting, stream routing, and exit translation. Each Typer verb invokes its corresponding facade export exactly once.

Canonical CLI argument/long-option spellings become parameter names by replacing hyphens with underscores; callback-local names and short aliases do not govern them. Repeatable options retain the singular spelling and accept a Sequence; dual flags use the positive semantic spelling (apply, follow). Positional arguments are positional-or-keyword and semantic options are keyword-only. No command accepts **kwargs. Presentation-only --json, --quiet, --verbose, --error, and --timestamps are excluded. Task-list --stats is also presentation-only; queue-list --stats remains semantic.

Submission receipts and returned Task objects prove broker acceptance, not manager readiness or completed execution. Confirmed acceptance survives a post-write readiness failure without changing receipt fields or assigning a second TID. Readiness-only failures are availability diagnostics rather than submission errors. An authoritative manager rejection retains its typed error and includes the accepted TID and rejection reason in the message. Direct manager lifecycle commands continue to require readiness proof.

A returned submission handle retains access to the accepted canonical task configuration, assigned TID, and effective resolved context needed by the shared result capability [MF-5]. These immutable values are usable before child initialization and after the submitting client closes. They are not a lifecycle snapshot. Python and CLI callers observe and retrieve task results directly through that capability; result meaning and errors remain shared. Bare-TID callers use the task-specific discovery boundary in [MF-5].

Implementation mapping: weft/core/spawn_requests.py::_submit_spawn_request_with_payload retains the committed transport; weft/commands/submission.py::_bind_accepted_taskspec binds it after acceptance/recovery; weft/client/_task.py::_task_with_configuration and weft/commands/run.py::_LiveRunSession retain it. Queue-based custody coverage lives in tests/commands/test_submission_custody.py, tests/core/test_client.py, tests/commands/test_run.py, and tests/commands/test_run_public.py. Snapshot custody coverage lives in tests/commands/test_point_snapshot_loans.py; its owner is weft/client/_task.py::Task.snapshot, through the shared point-snapshot capability. tests/commands/test_observer_command_failures.py covers typed observer failure and cleanup priority at public boundaries. weft/commands/result.py::_task_result_event projects the shared settled-result shape for consuming follow and stream adapters.

Related plan: Direct result observation spec changes. Implementation repair plan: Direct result observation. Related plan: Result observation simplification.

The exact signature exceptions are: cmd_run has spec_args=(), describe=False, and stdin_text=None; cmd_queue_write(queue_name, message=None, *, endpoint=None) exposes its positional overload; cmd_system_task_monitor(..., follow=False) exposes the positive side of --once/--follow. cmd_run(describe=True, spec=REF) returns spec-aware help metadata and never submits.

Public command types

weft.commands.__all__ exports the 41 functions and these consumption types: InitResult, RunSpecDescription, RunSession, CommandStream, RunExecutionResult, SubmittedTaskReceipt, TaskSnapshot, TaskResult, TaskEvent, ServiceSnapshot, TaskPingResult, TaskControlResult, TaskControlFailure, QueueEntry, QueueInfo, QueueWriteReceipt, QueueMoveResult, QueueDeleteReceipt, QueueBroadcastReceipt, QueueAliasRecord, EndpointResolution, ManagerSnapshot, SpecRecord, SpecValidationResult, SpecMutationResult, SystemStatusSnapshot, SystemTidyResult, SystemLoadResult, SystemDumpResult, SystemPruneResult, BuiltinSpecRecord, TaskMonitorConfig, TaskMonitorResult, TaskMonitorRecord, and TaskMonitorSummary.

New/refined exact contracts:

  • InitResult(root: Path, config_path: Path, created: bool).
  • RunSpecDescription(reference: str, usage: str, arguments: tuple[Mapping[str, Any], ...], stdin: Mapping[str, Any] | None).
  • CommandStream[T] is an iterator with idempotent close(); exhaustion and close release resources and iteration failures use typed command errors. Broker-backed stream advancement and final closure share an executing-thread owner. Transport adapters preserve that ownership across async delivery and cancellation; they do not close an iterator concurrently with an active advance. A genuine stream-body failure stays primary when cleanup also fails; a cleanup failure during lifecycle-only generator closure remains observable to the close caller.
  • RunSession exposes tid, events() -> CommandStream[TaskEvent], send_input(text), close_input(), stop() -> TaskControlResult, wait(timeout=None) -> RunExecutionResult, and idempotent close(). Close releases owned resources but does not cancel the task.

Implementation mapping: weft/commands/_resources.py owns stream cleanup exception priority; weft/commands/_result_wait.py, _spawn_submission.py, events.py, queue.py, result.py, system.py, and tasks.py own the bounded and generator observation scopes; weft/commands/run.py::_LiveRunSession owns input, direct cancellation wake, and child-stream closure on the thread that advances each iterator. It shares the plain callback registration helper weft/helpers/__init__.py::_register_observation_wakeup with Django. Cross-thread Django adaptation is mapped under [DJ-12.1] and [DJ-12.2]. Firing cleanup coverage lives in tests/commands/test_resource_cleanup.py, test_queue.py, test_status.py, and integrations/weft_django/tests/test_weft_django.py.

  • QueueMoveResult(source: str, destination: str, entries: tuple[QueueEntry, ...], moved_count: int); entries are the exact ordered moved set.
  • TaskResult retains its result fields and carries optional reconciliation: dict[str, Any] | None = None so the CLI can render the existing claimed-result metadata contract [CLI-1.2.2]. The owner is weft/commands/result.py::await_task_result; the CLI renders that evidence without another lifecycle probe. Implementation plan: 2026-08-31-dead-generation-retirement-plan (retired — source 396c3b85; see docs/plans/README.md).
  • TaskPingResult(tid: str, acknowledged: bool, timed_out: bool, error: str | None, observed_at: int | None, pong: Mapping[str, Any] | None, snapshot: TaskSnapshot | None). A synchronous keyed PING owns an ephemeral reply queue whose lifetime is bounded by the dead-task cleanup age. Its timeout must be finite and satisfy 0 <= timeout <= CONTROL_PING_MAX_TIMEOUT_SECONDS. A value outside that inclusive range raises CommandUsageError before any queue is created; it is not clamped and is distinct from a probe timeout.
  • TaskControlFailure(tid: str, error: str, error_type: str) records a selected task whose control attempt could not be confirmed. error is the rendered failure message and error_type is the exception class name.
  • TaskControlResult(command: Literal["stop", "kill"], requested: tuple[str, ...], accepted: tuple[str, ...], failures: tuple[TaskControlFailure, ...], snapshots: tuple[TaskSnapshot, ...]). accepted and failures partition requested. An empty selection is a successful zero-count outcome. In a sweep, the command raises only when at least one task was requested and none was accepted; the raised ControlRejected carries the full failure tuple on its documented failures attribute. A single genuinely unknown TID instead propagates TaskNotFound, which the CLI renders with exit code 2. A known terminal task rejects stop with ControlRejected and an already <status> message before a control queue is written. It remains eligible for kill so kill escalation can reap runtime residue; when no live runtime can be proven, the rejection states the terminal status and absence of runtime residue. Client stop_many()/kill_many() with no selector are the empty-selection case. Explicit tids cannot be combined with all_tasks or pattern; mixed scope is a typed usage error.
  • QueueDeleteReceipt(queue: str | None, deleted_count: int, queues_deleted: int, all_queues: bool, exact_message: str | None).
  • SpecMutationResult(action: Literal["create", "delete"], record: SpecRecord).
  • SystemDumpResult(path: Path, queues: int, messages: int, aliases: int, omitted_claimed_queues: int, omitted_claimed_messages: int).
  • SystemPruneResult(families: tuple[str, ...], applied: bool, candidates: int, deleted: int, failed: int, details: Mapping[str, Any]).
  • BuiltinSpecRecord(name: str, description: str | None, category: str | None, function_target: str | None, supported_platforms: tuple[str, ...], path: Path, source: str = "builtin").
  • TaskMonitorRecord(record: Mapping[str, Any]) is the lossless pre-serialization form of each run-start, task-summary, and run-completed record in exact emission order.
  • TaskMonitorResult(log_path: Path | None, records_written: int, events_scanned: int, tids_seen: int, summaries_emitted: int, checkpoint_timestamp: int | None, records: tuple[TaskMonitorRecord, ...]). It has no exit-code, stdout, stderr, or JSON-rendering fields.
  • TaskMonitorConfig(context: str | Path | None = None, follow: bool = False, sink: Literal["stdout", "disk"] = "stdout", log_dir: Path | None = None, checkpoint: Path | None = None, no_checkpoint: bool = False, since: int | None = None, limit: int | None = None, monitor_name: str = "default") and TaskMonitorSummary(record: Mapping[str, Any]).
  • RunExecutionResult has no submission_error; terminal status, result, and failure detail remain outcome data.
  • SpecRecord has payload: Mapping[str, Any] | None = None; show always supplies it. TaskEvent.payload losslessly carries lifecycle, output chunk, result, and control envelopes in observed order.
  • TaskSnapshot adds host_pids, managed_pids, and live_managed_pids, each tuple[int, ...] | None = None; process mode populates them.
  • ManagerSnapshot adds liveness: Literal["live", "stale", "unknown", "non_live"] | None = None, proof_source: str | None = None, proof_detail: str | None = None, dispatch_eligible: bool | None = None, canonical_candidate: bool | None = None, and canonical: bool | None = None; diagnostic mode populates them. started_here: bool | None = None is populated only by cmd_manager_start so the CLI can preserve its started-versus-existing lifecycle message without a second semantic query.

Deterministic return matrix

Family Return
root init InitResult
root status SystemStatusSnapshot, or CommandStream[TaskEvent] iff watch
root result TaskResult, tuple in all mode, or CommandStream[TaskEvent] iff stream
root run RunSpecDescription iff describe; otherwise RunSession iff wait; otherwise RunExecutionResult
queue read/peek/watch tuple/stream of QueueEntry
queue write/move/delete/broadcast corresponding receipt/result
queue list/exists/stats/resolve/alias named QueueInfo, EndpointResolution, QueueAliasRecord, tuple, or bool
spec create/delete/list/show/validate/generate mutation, record tuple/record, validation, or mapping as named
task list/status/ping/stop/kill/tid snapshots/events, ping/control result, or full TID
manager start/status/list snapshot or tuple; serve blocks and returns None
manager stop terminal snapshot, or None when already absent
system tidy/load/task-monitor/prune/dump/builtins corresponding named outcome

Interactive run only changes session capabilities. Task monitor returns CommandStream[TaskMonitorSummary] iff follow=True, else TaskMonitorResult; sink does not alter the branch. cmd_manager_serve is the sole blocking non-stream exception and emits no process output.

Client system dump, load, and tidy preserve the supplied resolved context, including its broker target and artifact directory. Without an explicit output, the export is weft_export.jsonl in that context's Weft directory; without an explicit input, load reads that same file. A RunSession.stop likewise controls the task on the context the session was submitted with.

Implementation: weft/commands/dump.py::dump_system and cmd_system_dump share the resolved-context materializer; weft/commands/load.py and weft/commands/tidy.py share theirs the same way, and weft/commands/run.py::_LiveRunSession.stop passes its session context to the task-control owner. Correction plan: 2026-09-08-complexity-review-corrections-plan (retired — source 396c3b85; see docs/plans/README.md).

Typed errors and CLI exits

The exact hierarchy is CommandError(WeftError), CommandUsageError(CommandError, ValueError), CommandTimeoutError(CommandError, TimeoutError), and CommandExecutionError(CommandError, RuntimeError). Existing InvalidTID, TaskNotFound, SpecNotFound, ControlRejected, ManagerNotRunning, and ManagerStartFailed retain their inheritance. Submission errors are SubmissionError(CommandError), SubmissionValidationError(SubmissionError), and SubmissionManagerError(SubmissionError). Backend, validation, and OS errors are translated with chaining at the command seam. Error classes exported by client and commands are identical objects.

The CLI maps CommandUsageError, InvalidTID, TaskNotFound, and SpecNotFound to 2; CommandTimeoutError to 124; every other CommandError to 1 except the spec-pinned system load alias-conflict case, which returns 3 before writes begin; success maps to 0. When an internal remaining-budget or completion-grace wait expires, the public timeout diagnostic retains the caller's requested timeout rather than exposing the internal sub-budget. Ctrl-C remains a shell concern.

Client submission with declared arguments [PY-3]

WeftClient.submit_spec(reference, *, spec_args=(), payload=None, stdin_text=None, **overrides) and prepare_spec(...) share the run pipeline: parameterization first, remaining tokens to run-input, before TID commit. For a spec declaring run-input, adapter output is the payload and payload= is rejected. Without run-input, payload= is valid. cmd_run and client spec submission accept a single stdin_text: str | None. The submission seam routes it to declared run-input stdin when the spec's run_input declares stdin, as the initial work payload when the spec has no run_input contract, and rejects it with a typed usage error when a run_input contract exists but declares no stdin. Client and command surfaces never read process stdin; the CLI adapter reads piped stdin once and forwards it as stdin_text. Failures are typed; a returned Task is the committed receipt and uses the materialized spec's runtime context. Runtime roots expand ~ and resolve to an absolute path through the shared submission seam on every surface. Declared-argument parse errors are CommandUsageError; malformed/materialization and adapter failures are SubmissionValidationError with the original exception chained, except that an adapter-raised WeftError retains its exact public type and mapping. A non-persistent human-readable name is not an endpoint claim and therefore does not use endpoint-name syntax validation; persistent names still do. payload= and stdin_text= are mutually exclusive even when the spec has no run_input contract, so the initial work payload has one owner.

Submit-override semantics are identical on every surface that accepts **overrides: an override whose value is None is ignored and the template's value stands; a name outside the shared public override vocabulary raises TypeError from prepare(...), prepare_pipeline(...), and submit_command(...), and SubmissionValidationError from prepare_spec(...); a value the TaskSpec schema rejects raises the schema's validation error (a ValueError) from prepare(...) and SubmissionValidationError from prepare_spec(...); a name in the reserved _weft. namespace raises ValueError. Each submit(...), submit_spec(...), and submit_pipeline(...) raises exactly what its prepare* counterpart raises. weft.client.normalize_taskspec_payload(taskspec, **overrides) runs the shared definition-normalization contract without a client or context and returns the validated, normalized TaskSpec definition as a fresh JSON-compatible dict: the definition snapshot used by prepare(...) before runtime-context binding, submission-time transport encoding, or reserved-metadata handling. Overrides are applied, re-validated, and JSON round-tripped. Pure normalization preserves a relative or home-relative spec.weft_context declaration as text. It accepts a TaskSpec or a JSON-compatible mapping (a mapping without tid is validated as a template), raises the errors from prepare(...)'s shared definition-normalization stage, constructs no Weft context, reads no configuration, resolves no project root, opens no broker, and writes nothing. Every keyword argument is an override name: payload is not part of the override vocabulary and raises TypeError here. The mapping carries no top-level bundle-root marker and no bundle provenance; a TaskSpec input that already carries a bundle root is accepted, its root is re-resolved read-only by TaskSpec validation, and the result drops it. Embedders that need a TaskSpec definition for composition rather than submission call it; there is no second normalization path.

Context-aware prepare(), prepare_spec(), and prepare_pipeline() bind a nonempty declared spec.weft_context to an absolute path using the preparation-time CWD and home expansion. Binding occurs after parameterization and overrides and before any run-input adapter observes the runtime root. An absent declaration stays unset and uses the captured client's root at submission. Binding copies and validates the TaskSpec without mutating its input, TID, io, bundle provenance, or request flags. It opens no alternate broker; broker selection for an explicit alternate root remains at submission using captured Config. Pure normalize_taskspec_payload() does not perform runtime-context binding.

Implementation mapping: weft/client/_client.py::normalize_taskspec_payload over weft/commands/submission.py::prepare_definition. Runtime context binding is owned by weft/commands/submission.py::_bind_declared_context, weft/commands/submission.py::prepare, weft/commands/submission.py::prepare_spec, and weft/commands/submission.py::prepare_pipeline. Related preparation input and error contracts are covered by weft/ext.py, tests/core/test_client.py, and tests/commands/test_submission.py.

Resource ownership rule for [PY-3]: weft/commands/submission.py::_submit_prepared_outcome(..., session=None) owns one bounded session when no session is supplied and borrows the supplied session otherwise. Both paths open one bounded connection operation for the committed spawn write and immediate manager observation, capture the accepted TID before operation exit, and release the operation before manager recovery. Submission never closes or recycles a borrowed session. Operation, session, or later recovery failures retain the accepted-TID annotation.

PreparedSubmission.submit() asks its owning client to select the retained or bounded path. Within an active same-context scope it borrows that client's session. After scope exit or in a forked child it uses bounded ownership with its captured context and never borrows a fork-inherited session. Every client submission method, including submit_command(), uses this seam. A TaskSpec whose effective runtime root differs from the base client context uses bounded ownership; clients do not retain a map of alternate-root sessions.

This also serves the Django on-commit adapter. See 2026-09-17-submission-manager-check-cost-plan (retired — source 396c3b85; see docs/plans/README.md).

Layering [PY-4]

Runtime imports are one-way: cli -> commands -> core, client -> commands -> core, and core -> ext; commands may also import ext. Commands never import adapters; core never imports commands or adapters; CLI and client never import each other; runtime ext imports never point back to core, commands, or adapters. Type-checking-only extension annotations may refer to public client schema types. The client package initializer may re-export only the declared schema types from weft.core.taskspec.model; this is a value-type facade exception, not permission for client operations to call core. CLI and core initializers remain import-light markers; client and commands are public package facades and ext is a public module. Architecture tests enforce the graph, facade inventory/laziness, CLI bijection, no command stdin access, and exactly one matching facade invocation per Typer callback.

Related Plans

Implementation plan: Task-owned observation and read separation.