# Shared runtime placement (`shared-runtime-v1` + `stateless-v1`) This source declares `execution.sharedRuntime: shared-runtime-v1` and `execution.componentModel: stateless-v1` in `manifest.yaml`. It is a source declaration only: it is **not certified**, has no signed archive, and has not been accepted against a live multi-Workspace deployment. The exact archive still needs independent review and acceptance runs. Under shared placement one `ScheNotify` object and one of every declared component serve every installation of the artifact digest. Installation attachments are lightweight contexts; they do not create another object graph. ## Per invocation - The `language` config is read through `self.plugin.get_config()` inside the command and Tool components that use it. No config value is stored on the shared object or in a scheduled event. - `get_installation_binding()` is read per invocation and never retained past the call; it decides which installation's schedule `add_scheduled_event`, `get_scheduled_events` and `delete_scheduled_event` read and write. - Inside one installation a reminder belongs to the full session identity `(bot_uuid, target_type, target_id)` derived from the trusted platform session. The `!sche` and `!dsche` commands pass that key, and `delete_scheduled_event` refuses to remove a record that does not belong to the caller's session, so two bots of one installation, or the person/group namespaces with the same id, are never treated as the same session. - `initialize()` is process-scoped and only logs: it runs once per worker with an empty config, before any installation is known, so it starts no scheduler. ## Per binding, released on revocation - `_events` is keyed by the installation scope `::` and holds that installation's pending reminders only. `runtime_revision` is deliberately excluded from the token, so a worker upgrade does not orphan a tenant's pending reminders within the process lifetime. - `_loops` holds one minute-scheduler task per installation scope. - `BasePlugin.on_installation_revoked(binding)` cancels that installation's scheduler (bounded 5 s wait) and drops its pending reminders, so a revoked installation can never fire an already-scheduled notification afterwards. - The schedule is in-memory only (unchanged pre-existing behaviour): nothing about a reminder is written to Host storage, so revocation has no durable row to clean up. - Dedicated (binding-less) workers keep a single binding-less schedule and their existing behaviour; their schedulers are stopped by process shutdown. ## The detached scheduler Each installation's scheduler is a detached task, registered per installation and stopped by revocation. It carries its installation scope as an explicit argument and re-enters the invocation it was started from: the handler, config and binding captured at scheduling time are passed back into `bind_invocation(...)` for the whole life of the loop, so every `SEND_MESSAGE` a firing reminder makes is attributable to the installation that scheduled it instead of falling back to the worker's connection default. `on_installation_revoked(binding)` cancels the task, which exits that scope and drops the authority with it; the same hook drops the installation's pending reminders, so a revoked (or upgraded) installation can neither fire nor send afterwards. Because the scope token excludes `runtime_revision`, a worker upgrade presents a new binding for the same token; the next scheduling invocation then cancels the superseded task and starts the loop again under the new binding, while the installation's pending reminders are kept. The scheduler never reads config or any other invocation-scoped API itself: delivery is language-independent (`NOTIFY_PREFIX`). ## Known limits - The scheduler is in-memory: pending reminders are lost on process restart (also the pre-existing behaviour). - Editing the `language` config only affects messages produced by later invocations; notification delivery itself reads no config, because the prefix is language-independent (the previous per-language lookup produced the same literal for both supported languages and was removed). - `get_scheduled_events()` returns a new list of the pending event records; the records themselves are the live dicts, so callers must not mutate them. - One installation's queue holds at most `MAX_EVENTS_PER_INSTALLATION` (100) reminders and each stored message is capped at `MAX_MESSAGE_LENGTH` (2000) characters; further reminders are rejected with a message instead of growing the worker's memory. A delivery that the Host refuses is kept queued with its failure count recorded, so it is retried on the next pass instead of being dropped. - Per-installation state is only freed by revocation. The runtime revokes on uninstall, disable, Workspace removal and upgrade. - Revocation is serialized per installation but may overlap with sibling installations' invocations; per-installation state is isolated, nothing is shared. ## Tests `tests/test_installation_schedule.py` drives two distinct `InstallationBinding`s (same instance and Workspace, different installation uuids) through one plugin object with the installed SDK's `bind_invocation`, over a stub Host that records notification relays. It asserts that B's `get_scheduled_events` returns only B's reminder (pre-fix it returned both, from one shared list), that A's scheduler fires only A's due reminder and leaves B's pending, that the scheduler is registered per binding and stopped by revocation with the revoked installation's schedule dropped (no late delivery) while a sibling's schedule survives, that `initialize()` starts no scheduler, and that the scope token is the full binding. The second-round tests add: the detached relay's Host call carries the scheduling installation's binding (pre-fix `None`); a worker upgrade restarts the loop under the new binding instead of delivering with the superseded task; `!sche`/`!dsche` and the Tool use the full session identity, so another bot or namespace with the same id is neither listed nor deleted (pre-fix the command listed and deleted the other session's reminder); a refused send stays queued with its failure count; a sibling delete during the awaited send no longer raises `ValueError` from `list.remove`; and the queue is bounded while the reminder text is no longer written to the debug log.