Invariants that are easy to break
Rules the code relies on that a reasonable-looking change can quietly break. Each says where it is enforced, so a change near it can keep it. The map of the code is in architecture.md.
The user’s machine
- The user’s own agent settings and config files are never edited. Claude gets per-session
--settingsand--mcp-configfiles, Codex-cflags and thread config, Devin a copy of its config with PDT’s hook added. Codex’snotifyanddeveloper_instructionsare chained or prepended, never replaced (pdt-agentshooks.rs,mcp.rs,adapter/). - “Always allow” is session-only for every agent (Claude
destination: "session", CodexacceptForSession, ACPallow_session), including switching mode after a plan. An agent’s own option that saves a rule beyond the session (Cursor’s “Allow always”, Grok’salways-allow) is never offered or picked; one kept for the session only (Antigravity’s “Allow Always”, in its server’s memory) may be. - Children never inherit pdtd’s
PDT_*variables (PDT sets the session’s own afterwards), so a dev pdtd started in a stable-hosted terminal can’t pick up the stable port or data folder. Agents also loseANTHROPIC_API_KEY/OPENAI_API_KEYunder the subscription policy (pdt-coresession.rs). Claude conversations never get--bare(it drops the plan login). - The stable instance (port 7420) is the user’s. Agents never stop, restart or update it, never call its
/api/shutdown, and tests never touch it or the real data folders (testing.md). Only pdt-host and the user’s Update do. A dev instance never updates; updates never install an older version.
Talking to agents
- Nothing is sent to an agent without the user’s click or a workflow the user enabled. Built-ins such as PR
notices are enabled by default and can be turned off. Messages from workflows go through the outbox
(pdt-api
workflow/outbox.rs), which waits until a terminal has been idle with nobody typing, or a conversation is idle with nothing open; a message that can’t be delivered endsuntoldand notifies the user. A session whose state is unknown (its hooks never fired) is never typed into. The one exception is a watch (pdt-apiwatches.rs): a PM conversation that set one with itswatchtool is told about its target without asking anyone, since it only tells the conversation that asked. Its messages still go through the outbox, and a closed conversation’s go to the user’s Inbox instead. - Conversations are never auto-resumed, and never as terminals. After a pdtd restart their running turn ends as
interrupted; the UI offers Continue. Only interrupted terminal sessions are resumed by themselves. Background
tasks a restart stopped (kept per session,
sessions.background_tasks, moved tostopped_backgroundbyrecover_conversations) are told to the agent only along with the next message the user or a workflow sends (pdt-apiconversations/mod.rstell_stopped_background); a workflow step waiting on them gets that message at once, as an automatic nudge (workflow/agent/recovery.rsrestarted_while_waiting). Terminals don’t report background tasks, so their agents aren’t told. - Text from outside is fenced. Issue and PR text (titles too), webhook payloads and other outside fields
(
EventType.outside) are fenced when they reach an agent (pdt-corefence.rs, pdt-apiuntrusted.rs), and sessions that read untrusted input run locked down (persona::locked_down: read-only tools, no secrets). Bots’ comments and reviews are outside text whatever their role on the repository (they relay others’ text). A sub-workflow’s run inherits itsworkflow.callchain’s first run (untrusted::origin,StepCx::origin): when that run’s trigger carries outside text, all of the child’s{{trigger…}}is fenced (fence::ALL), its sessions are locked down and outsiders’ text is refused as in the caller; the triage cap counts a workflow whose calls reach an agent step (engine::reaches_agent). New code that reads “was this run started by outside input” goes throughuntrusted::untrusted_run/run_source, never the run’s own trigger alone. - What every user’s agents read is about their project, not PDT’s development. The built-in personas’
instructions, the PM’s prompt, the tools’ descriptions and schemas (doc comments on their argument types too), the
agent context and the served guides name no ticket keys of PDT’s own (examples use
ABC-12), and nothing of PDT’s own changelog, release workflow, nightlies or repository. Release guidance reaches the PM only through its briefing, for a project with an enabled release workflow. PDT’s own process lives in AGENTS.md and docs/SDLC.md. pdtd’sprompts_and_guides_carry_no_pdt_development_assumptionschecks it (with an allow list). - Routes that act as the user need the UI token (
x-ui-token, new at each start; pdt-apiuntrusted.rs): admitting an external ticket, filing issues, the PM’s approvals, approving a run configuration’s commands, deciding a ticket proposal, Google’s sign-in and picker token. This stops accidents (an agent calling the REST API approving its own prompt or proposal), not a process that means to:GET /api/ui/tokenhands the token to any request that sendsSec-Fetch-Site: same-origin, which a local process can (see “Requests” for the trust model). A new route that acts as the user still checks the token, and nothing may treat it as proof a person clicked. - A run configuration’s commands run only once the user approved them (pdt-core
command_trust.rs, pdt-apicommand_trust.rs): runs and tests (runs::create,restart, andrun_manager’s restarts), the local forge’s[ci] command(it waits, with an Inbox notice) and[[merge.regenerate]]rules (read from the base branch’s committed file, never the worktree’s). An approval is the command’s fingerprint (setup, command, env), kept intrusted_commands; only the UI token approves (POST …/commands/trust, or overrides saved from the web app). Projects from before carry alegacymark that trusts the main checkout’s commands once. A new place that runs a configured command checks it first. - Making tickets over the HTTP API records the user only with the UI token (pdt-api
tickets::creator, also parts): without it the caller is an agent, the session itsx-agent-sessionheader names (persona, the ticket it works on), and the ticket’s source isagent(pm,externalfor a PM or triage session). The web client sends the token when it creates tickets and parts; a new route that makes tickets usescreatortoo. - Only the user decides a ticket proposal (pdt-core
ticket_proposal.rs): a held ticket isproposedfrom the transaction that makes it (tickets.proposed_at), before its Inbox item exists. The store refuses an agent’s move of it out of Draft (closing it is allowed) or itsagenticlabel; without the UI token, PATCH, bulk actions and Start refuse the same (untrusted::decides_proposal), and auto-start never starts it; the Inbox answer needs the UI token. Any move out of Draft releases it and closes the openticket_proposalitem; deleting the ticket removes it. Lists hide a ticket only while that item is open, so a held ticket with no item is never out of the user’s reach. - The PM’s
inbox_decidenever decides without the user’s Allow (pdt-apimcp/tools/inbox.rs): it isasks_in_inbox(pdt-coreToolDecl::inbox_only), so it is in Claude’s--allowedTools, has noask_target(no project policy answers it) and always opens aconfirmitem withbody.rows; only that item’s Allow, which needs the UI token, runs it withcx.confirmed, narrowed to the rows the user kept. It refuses non-PM sessions even when a persona lists it. Each row goes throughticket_proposal::decideasBy::user_via(<the PM>): the user’s events, with the PM asproposedBy. - An asking tool asks somewhere the user sees (pdt-api
mcp::tools::confirmable): Claude’s permission prompt only while the session isn’t in Bypass (Session::asks_nothing); otherwise, as for Codex and Devin, unless the project lets the PM act, a conversation gets an approval card of PDT’s own (conversations::held: aRequestevent whose id startspdt-ask-, which the Inbox lists as its approval item) and the tool call waits on it; a terminal gets an Inboxconfirmitem.answer_requestanswers PDT’s own requests itself, never the agent’s process, andpm::request_openednever answers them. Codex conversations gettool_timeout_secfor pdt-mcp so the wait outlives Codex’s one-minute default. AToolDecl::publictool (posts on the forge) asks in the Inbox outside an active agent step. A new path that runs an asking or public tool goes throughconfirmable(AnyAction::calldoes for actions). - Tool lists fail closed (pdt-api
mcp::session_tools, pdt-corePersonaSetup::tool_set): an empty persona tool list is no tools, and a persona version that can’t be read gives none. Only a session with no persona getsDEFAULT_TOOLS. - Fresh Codex launches in one folder stay at least 5 s apart, or PDT can’t tell their conversations apart.
Tabs, tickets and attempts
- A session, run, ticket or worktree’s changes has at most one tab, and closing a session’s or run’s tab stops and
removes it (unique columns on
workspace_tabs). - One active attempt per ticket (the
one_active_attemptindex; 409ticket_active). - Parent tickets are never started; their parts are (409
ticket_has_parts). - PR state, CI, file overlaps and parts progress are computed in memory and added to ticket responses, never stored. A restart forgets them until the next poll.
- A branch counts as merged only after it had commits of its own (
own_commits, sticky), so a branch that only fast-forwarded to the base is not merged. - An automated merge never merges locally when a PR may exist, and needs green CI from every provider and the
policy’s approval; a person’s merge skips those gates (pdt-api
review::merge_attempt, pdt-corepolicy.rs). Where the forge can’t hold a merge (no auto-merge), a person’s merge waits for green CI too, and PDT’s own merge once it passes is that person’s request: it skips the policy, as the forge’s auto-merge would (review::merge_held). - Cleaning build output never removes code or work. A build output path is removed only when git ignores it and
tracks nothing in it (pdt-git
is_disposable, checked per path by pdt-apistorage::clean_checkout), and never while a session or run works in the worktree. The automatic cleanup touches only worktrees of closed tickets with no active attempt, and removes a worktree only throughremove_worktreewithout force or deleting the branch. - PDT’s git never takes optional locks (
GIT_OPTIONAL_LOCKS=0on every git pdt-git runs). A read such asgit statuswould otherwise refresh the index underindex.lock, failing a git command the agent runs in the same worktree at that moment (the background size measuring reads worktrees agents work in). - PDT’s git never runs the repository’s own programs or reads a value as an option. Every git pdt-git runs has
core.fsmonitor=false(a repository’score.fsmonitoris a commandgit statusruns), PDT’s own commits (PDT_COMMIT_CONFIG) run no hooks, and a branch, base or commit from a request or a forge goes after--end-of-options. Bases are checked before they are stored: a project’s base names a branch of its repository (pdt-apiprojects::valid_base), a Start’s base a commit, and a forge’s head SHA is used only when it is a full commit id (is_commit_id). - Finish never discards uncommitted changes unless asked (
discardChanges), and runs in its own task so it completes when the agent that called it is stopped. - Workflows hear of a Start only once its session exists (
announce_start). - Only the user’s and the PM’s ticket comments go to the agent (
ticket.commented’sauthor:user, else the writing session’s persona), never a workflow’s (workflowId) or the ticket’s own agents’, so an agent never hears back what it wrote; the API refusesby: "system". A comment’s attachments’ local paths are filled in by pdt-api as runs see the event (trigger_value), since the store doesn’t know the data folder. - Hiding a session from the lists never hides it from attention. The Agents list (the sidebar, the phone drawer
and
/sessions, allAgentsSectionin webworkspaces/Sidebar.tsx) leaves out every session a workflow step started (startedByStep, websessions/status.ts), tab or not. Bring here and the palette readuseWorkspaces.allInfos, which leaves out workflow step sessions for a ticket and ended ones with no ticket unless a tab holds them (hiddenStepSession), so a step’s tab can still be found without old steps piling up. The favicon and title readeveryInfo, every session, so a hidden step that needs the user still shows. - A session in the background is never news. A running agent that is idle with background tasks (
Session.background, the store’sbackground_taskscolumn, kept in step with the conversation’s) shows Background (webcommon/AgentState.tsxinBackground), and its unseen flag is held back (sessionUnseen, read bysessionInfo), so no Done, unseen dot or favicon. Clearing the last task while the agent is idle setsattention_at(set_session_background), so it turns Done (unseen) then, even if the user looked meanwhile.
Workflows
- Everything runs at least once, maybe more. Events are deduplicated by their key, runs by their run key. A new
node kind with side effects either records its progress or sets
rerun_safe = false(pdt-apiworkflow/catalog.rs), so a step cut short by a crash isn’t repeated blindly. - A node kind’s config changes incompatibly only with a version bump and a migration. Stored documents keep each
node’s
version; the engine and validation bring an older config up to date as they read it (NodeKind::migratein pdt-apiworkflow/catalog.rs; e.g.github.nightly1 → 2 inworkflow/nodes/release.rs), so a user’s workflows run unchanged after an update. Never rewrite their documents for it. - Never hold
engine_lockacross a slow node body. Action and agent bodies run outside it; a flow, gate, wait or condition kind that does slow work (git, likeattempt.has_changes) opts out withNodeKind::in_engine_lock. A result is written only if the step is stillrunningwith the same attempt. - Never hold a global lock across git or the network. Ticket Start fetches the base before it takes its
project’s start lock (
AppState::ticket_start_locks), which covers only the checks, the worktree and the rows. - Secrets are filled in only when a node runs, only from the node’s own config, never from text that arrived as
data. Stored step inputs, outputs and errors are redacted. Errors from outbound HTTP go through
send_error, which drops the URL (it can carry a secret). - Outside text never reaches a shell as code. A
commandstep’sscriptrefuses{{…}}references (only a template’sparamsmay stay); they go throughenv.checkrefuses them on save, andNodeKind::check_at_run(pdt-apiworkflow/catalog.rs, called by the engine before it resolves a step’s input) stops a workflow saved before the rule. A rule that guards against injection belongs incheck_at_runtoo, since stored documents aren’t revalidated. - Event payloads are an API. Users’ workflows read them as
{{trigger.x}}; renaming a field breaks them silently. - A workflow never reacts to its own runs by default. The events in pdt-core
events::OWN_RUN_EVENTS(run.*,inbox.*,session.needs_you,session.turn_ended,session.exited,agent.stalled,agent.loop_limit,agent.timed_out) carryworkflowId: the workflow whose run caused them (the run, its agent step’s session, its Inbox item).EventTrigger::acceptsskips them for that workflow unless the trigger setsincludeOwn. A new event a run can cause about itself joins the list and carriesworkflowId. Runs started byrun.*events count achain, and oneMAX_RUN_CHAINdeep logs norun.*event, so two workflows reporting each other’s runs stop. - Notifications never wait on a run. The Notification rules’ levels and quiet hours are read from its document as
each notification is made (
notifications::rules); only escalation and chat posts run as workflow steps. With the workflow off or every workflow off, every kind still notifies at its default level. - A stuck agent step is never left without anyone told. A step handed to “When an agent is stuck” has no Inbox
item until the workflow acts;
stuck::sweepfalls back to PDT’s own handling when it is off, its run ends without acting, or no run starts in time. Code that ends or moves a step on takes its escalation (take_escalation). - Only Install updates installs by itself. The update loop checks and logs
update.available; it never installs. An automatic install goes throughhost.install_updatewithautomatic, which keeps to the host’s policy and never retries a version pdt-host skipped. With the workflow off, nothing installs without the user. - Only Clean up storage cleans by itself, and only a pause pauses pickup. The storage loop measures and logs
disk.low/disk.recovered; it never cleans or pauses. Auto-start checkspickup_pause::paused, never the disk level, so with Clean up storage off a full disk only warns. A pause stays over a restart until apickup.resumeor the user resumes it. _pdt.*state keys are PDT’s own.- Every manual start checks the Run now trigger’s declared inputs. Run now,
POST /api/workflows/{id}/run, the PM’sworkflow_run, aworkflow.calland a manual dry run all go throughManualConfig::input(pdt-apiworkflow/nodes/trigger.rs, the rules in pdt-coreworkflow/inputs.rs), which fills defaults, drops hidden fields and refuses anything undeclared. The web form (web/src/workflows/runInputs.ts) mirrors the rules for display only; the server’s check is the one that counts. Never start a manual run around it. - A failed write is not a step’s failure. A write the database can’t make (a full disk, an I/O error, a lock held
too long) is a
StoreError::Write, whose message starts withWRITE_FAILED(“PDT can’t save changes”); text that carries it (pdt_store::is_write_failure) makesfail_stepwait and retry the step without using up one of its retries, an event whose run could not start stay unprocessed, and a step’s result that could not be written wait inUnsavedResults(the body is not run again). Pass the store error to a node’s error throughworkflow::err(whose text keepsWRITE_FAILED, and says when the disk is full), never replace it with your own words. A route answers it with 503write_failed, sayingWRITE_FAILEDand whether the disk is full, not SQLite’s text (ApiError::write_failed: from aStoreError::Write, a full disk or read-only volume inApiError::io, orApiError::from_textgiven an engine error’s text;ApiError::internaldoesn’t look at the text), and the router’snote_write_failuresrecords it, so the Inbox says so whoever wrote; aBEGINthat can’t start (Store::write_tx) is recorded too. - An event is marked processed only after it was matched against everything.
match_eventsreturns before marking any event when it can’t read the new events, the enabled workflows (enabled_with_docsgivesErr) or the steps waiting for events: an empty list from a failed read would drop the events for good. Neverunwrap_or_default()those reads. A workflow whose document is corrupt is left out with a warning instead, so one bad row doesn’t hold up every event. - Conversation events are never skipped.
conversations/live.rsappendapplies an event to the runtime state as it comes (a message the agent took leavespending, a turn’s leftover streamed text goes), then stores it through the conversation’sunstoredqueue: one that can’t be written waits there with every event after it, is retried with a backoff and stored in the order received, and only a stored event goes to viewers. What reads the log (a turn’s end, the PM’s hooks) runs once the event is stored. A Claude conversation’s log is checked for gaps against Claude’s transcript the first time it loads in a pdtd (repair_gaps, by item id; only messages, tool calls and reasoning, since turn markers of turns the agent starts on its own have other ids live). - An agent’s output is read with backpressure. An adapter sends into an
EventSink(pdt-agentsadapter/sink.rs) without waiting, and its reader awaitssink.ready()before the next frame, so a conversation whose store lags pauses reading the agent’s pipe (Codex: the whole app-server connection). Read lines withadapter/lines.rsCappedLines(an overlong line is skipped), neverlines(). Theunstoredqueue gives up its oldest events past a count and a byte bound; PTY input waiting for a terminal that doesn’t read it is bounded too. - A step’s setup error is never retried. An error that waiting won’t fix (no project, a bad repository) is made
with
setup_error(pdt-apiworkflow/catalog.rs;repo_for_stepinnodes/release.rs), andfail_stepskips the node’s retries for it. A kind that works on the run’s project says so inNodeKind::needs_project, so a manual start of a workflow without a project is refused and Run now asks for one (WorkflowDetail::needs_project). - Built-in workflows, templates and personas follow newer versions only while the user hasn’t edited them. Repo
workflows (
.pdt/workflows/) are read-only through the API; imported and instantiated workflows start disabled. - Enabling a workflow is consent to the version it has then. A changed repo file of an enabled workflow is a
proposal (pdt-api
workflow/repo.rs), never a saved version, so a merge can’t swap in new commands that run on the next trigger; only a disabled one takes the file’s version directly.
Secrets and GitHub
- Agents and workflows use secrets by name; values never go in the chat. Values never go in the database, a
response, a log or a tool result;
pdt_core::secret::Redactorreplaces them (and their hex, base64 and URL-encoded forms) wherever the store, pdt-mcp, conversations and the logs write. It is a safety net, not a wall: a command run with a secret can print it some other way, so the docs promise “never pasted into the chat”, not “never seen”. - Every keychain value PDT reads or writes reaches the redactor.
AppState::secretsis wrapped insecrets::Redacting(pdtd’smain.rs,AppState::new); a value kept outside the keychain (Google’s access tokens, MCP sign-ins’ refreshed tokens) is given to the redactor where it is made. - Redact before cutting. Output cut to a size is redacted first and then loses any end that could start a value
(
Redactor::string_cut), so a cut never leaves part of one. - A Codex terminal’s secrets stay out of its shell commands: each name
codex_tui_envpasses a secret in is in theshell_environment_policy.excludeit gets (pdt-agentsmcp.rs), after the user’s own patterns. - The secret tools are no way round the agent’s sandbox.
run_with_secretsandwrite_secret_filerun in pdtd, so they ask (unless the session asks before nothing), must name a secret, and stay in the session’s folder, out of.gitand off symlinks (pdt-apivault.rssession_path). Don’t mark them non-asking or let the PM act for them. - Tokens are never returned by the API or logged, and follow a redirect only to the same host.
- An MCP server’s sign-in addresses are https (or loopback http) before PDT uses them: its authorization
server and every endpoint in that server’s metadata pass
pdt_core::mcp_servers::endpoint_ok, and the metadata’sissuermatches (AuthServer::check, in pdt-apimcp_oauth.rsdiscover), because the browser opens the sign-in address in PDT’s own page and PDT sends the token address a client secret. The UI also opens only http(s) addresses. - A dev instance never reads stable’s keychain entries: keys outside the stable data folder are scoped by the data
folder (pdt-api
secrets.rs). - On Linux the secret backend sticks to its data folder (keychain or file); it never switches.
- Agents never attach secrets. A file an agent attaches by path (
filesonadd_ticket_comment,create_ticket,edit_ticket) is resolved and must be inside the session’s folder, the temporary folder or the data folder (ticket_attachment::outside_roots), and passesticket_attachment::path_refusal: no dotfiles, no key or credential folders, nothing in the data folder but conversation and ticket attachments. Both compare without case on macOS and Windows. A session that triages outside input attaches nothing (ticket_attachments::read_files). Attachments are never downloaded from outside issues or PRs, and a text attachment on an external ticket reaches agents fenced (get_ticket). - The Files view never leaves its checkout or touches
.git. Paths are cleaned (files::clean_path: no.., no.githowever spelt,.git.andGIT~1included), checked, and then read and written only through a handle on the checkout (files::checkout_dir, cap-std), never by plain path, so a folder swapped for a symlink after the check still can’t lead out. The review diff reads the same way. - Nothing runs
ghorglab. Forges go through their crates over HTTP (pdt_forge::transport); agents use pdt-mcp’s forge tools.
Requests
- Anything running as the user on this machine can act as the user in PDT, by choice: agents, terminals, runs and
any other local program. The UI token and
from_uichecks only guard against accidents (above); they are not a boundary. The boundaries are the Tailscale owner check for remote requests, the Host and Origin checks against web pages (DNS rebinding, CSRF), and fencing of outside input sent to agents (“Talking to agents”). Don’t build on the UI token as if it kept a local process out, and don’t weaken the real boundaries. - The Host guard and owner-only checks cover every route (pdt-api
host_guard.rs,identity_guard.rs): only loopback hosts and the allowed hosts; remote requests carry the owner’s Tailscale identity. Hooks (/api/hooks/*) authenticate with their own secret instead. Don’t add a route outside the guards. - Every response carries the security headers (pdt-api
security_headers.rs, the router’s outer layers): no framing (X-Frame-Options: DENY,frame-ancestors 'none'),nosniff, no referrer, and for the web UI a CSP with script only from PDT’s own bundle. Keep the UI free of inline scripts andeval; a new kind of resource it loads (a worker, another frame source) needs the policy widened there. A response with its own CSP keeps it. - An error’s message never carries another program’s raw text (an API error’s
message, a step’s error, an Inbox item): the web shows it as it is, in screenshots and bug reports. A database, git, spawn, keychain or file error goes through itsFromintoApiError(orApiError::io(doing, e)), which logs the full text and says what failed: git’s failing line and a keychain’s first line byerror::short_text(absolute paths cut to their last part), a database errorDB_UNREADABLE, the restinternal. Workflow nodes useworkflow::err, which does the same (workflow_errors_go_through_errchecks). Text that is the user’s own input (a path they typed, a workflow’s bad input) and a forge’s or agent’s answer meant for them stay as they are. - A run’s link is set under
settings_lock, together with registering its driver (run_manager::relaunch,runs::create). Applying remote access holds the same lock while it relinks and restarts the running runs, so a run started meanwhile never keeps the other setting’s link.
Code and data
- Transactions go through sqlx’s
Transaction(Store::write_tx,pool.begin), never a hand-writtenBEGIN. The pool’safter_releasecloses any connection still in a transaction (pdt-storehealth.rs): after a full disk SQLite rolls a transaction back itself, sqlx’s own ROLLBACK then fails, and the connection would refuse every laterBEGIN. - Never edit an applied migration (sqlx checksums them); add a new one named by its UTC creation time. Data
backfills that run when the store opens go in
Store::open. .pdt/project.tomlmust stay readable by the stable release. Unknown top-level sections are skipped, but unknown keys inside[[run]],[[test]]and[[area]]are refused, so a new section waits until stable supports it.- Names live only in
crates/pdt-core/src/brand.rs(plus the Tauri configs, the Windows installer hooks and the release scripts): product name, slug, daemon, ports, release repository, keychain service, also in UI text. - PDT never special-cases its own repository or development process; PDT’s own automation lives in the
maintainer’s workflows. Outside tests, only updates, bug reports and the release repository setting name the
release repository (
brand::RELEASE_REPO; a test in pdt-corebrand.rslists where). Nothing is installed or offered by what a project’s repository is. The built-ins that once automated PDT’s development are kept on the hosts that had them as their own workflows (pdt-apiworkflow/builtins/mod.rs,RETIRED); never ship such a built-in again. Cut a release is a template (filled in per project), not a built-in. - IronCalc is one version on both sides: pdt-sheet’s
ironcalc(pinned with=) and web’s@ironcalc/workbookexchange IronCalc’s bytes and diffs, so they are updated together (pdt-sheet’sthe_web_ui_runs_the_same_ironcalcchecks). - Guides served by pdtd link to each other by API path, never by repository path (a test checks).
- The web doesn’t test for an integration’s id or name: what a plugin adds to the UI comes from what it declares
(its connection kinds, project settings schema, label, account sign-in, a document provider’s picker, a forge’s
name for a change request), so turning it off removes it. Add a declaration rather than a check on
kind === '…';git grep -i -E 'google|gitlab' web/srcfinds only generated types, fixtures and tests (and Google’s ACP agent, which is an agent, not a plugin). - New tools and node kinds change the integrations snapshot (
crates/pdtd/src/integrations/): update it on purpose. - The UI re-renders every 2 s: effects must be idempotent, and never focus from an inline ref callback.
- The shell re-renders only when a poll brings something new (web
shell/AppShell.tsx): theuseWorkspacesanduseBacklogresults are memoized, the views it passes them to (Sidebar, WorkspaceBar, SessionView, RunView, BottomPanel) arememo, and its handlers areuseCallback. Keep new props to those views stable, and read a fast- changing query in the shell through aselectthat keeps only what it shows (usePmSummary). - What a view shows when it is left comes back (web
common/viewState.ts): keep a view’s choices (a file, a toggle, a scroll, a draft) withuseViewState/useRememberedScroll, notuseState, under a name that includes the ids of what it shows. The shell scopes them by workspace tab (or by view); a closed tab’s are dropped. - A failed mutation is reported once. The app’s QueryClient (web
common/queryClient.ts) toasts a mutation’s error unless it has its ownonErrorormeta: { errorShown: true }. Give auseMutationthat renders its error inline (or awaitsmutateAsyncin a save status) that meta, and a hook that returns its mutation’s error the same, so the error doesn’t show twice; leave the rest to the fallback toast.