Skip to content

Support creating new Google Docs via createExternalResource - #428

Draft
ndisidore wants to merge 14 commits into
nathan/create-external-resourcefrom
nathan/create-external-resource-google
Draft

Support creating new Google Docs via createExternalResource#428
ndisidore wants to merge 14 commits into
nathan/create-external-resourcefrom
nathan/create-external-resource-google

Conversation

@ndisidore

Copy link
Copy Markdown
Member

Google Docs is the first creatable resource. A doc title isn't globally contended, so this proves the kernel API without the approval-time rename that github repo names will need. Also supports a common use case, i.e. a scheduled task minting a doc each run, whose queued appends would otherwise wait on a human click every time

A created doc gets a provisional-<uuid> id and resolves to the real id when the creation applies, via the kit's ProvisionalIds (cherry-picked at its own paths so nathan/gatekeeper-kit rebases cleanly). Before that, the binding simulates over an empty document and makes no provider calls. The existing in-order rule applies the creation before any queued edit. Rejecting the creation invalidates every queued edit and makes session methods fail with an explanation of what happened. No new OAuth scopes; the doc grant already carries write access.

Creation is auto-approvable under its own opt-in rule, separate from edits.

@github-actions github-actions Bot added the gatekeeper Changes to a gatekeeper integration label Sep 2, 2026
@ask-bonk

ask-bonk Bot commented Sep 2, 2026

Copy link
Copy Markdown
  • High packages/gatekeeper-google/src/google.ts:1811: Pending observers are not tracked or rechecked after creation, allowing unauthorized access to the real document.
  • High packages/gatekeeper-google/src/google.ts:1675: Removing the action immediately makes a lost apply response unrecoverable; retries throw “Unknown pending” and the approval remains stuck.
  • Medium packages/gatekeeper-google/src/google.ts:822: The provisional URL remains in creationSpec, so suggested blueprint bindings recreate a permanently unresolved document.
  • Medium packages/gatekeeper-google/src/google.ts:1584: Auto-approval rules are gatekeeper-ID scoped. Each creation gets a new ID, so opting in cannot auto-approve future scheduled creations.
  • Medium packages/gatekeeper-kit/vitest.config.ts:1: No Vite+ test task is defined, so root pnpm test skips the new package’s tests.

github run

Vendors advertise creatable resource types via SupportedResource.creatable;
GatekeeperUser.createResource() mints a provisional identity (no provider
call, no user interaction) and returns an imbued gatekeeper class the way
getGatekeeperClassFor() does; Gatekeeper.submitCreationAction() queues the
"create this resource" action so the provider-side creation rides the normal
action-approval flow while the gatekeeper simulates the resource locally.
All three are optional and additive; no existing gatekeeper changes.
Like requestConnection except it creates a brand-new resource of a creatable
type through an already-connected account, and no user action gates the
binding: it lands in the chat's env immediately (the createGadget pattern --
recorded output drives replay and chatScopeNames), the turn continues, and
the creation is an ordinary pending action attributed to the chat, so its
card rides the existing consumeCapturedActions splice.

* agent.ts: tool definition, AgentHooks.createExternalResource, replay case,
  a system-prompt sentence. Fixable rejections (bad name, unknown vendor or
  type, no usable account, ambiguous accounts) return non-error tool results
  so the model retries in-turn; with several connected accounts the message
  enumerates ids so the agent can retry with accountId or ask the user.
* user.ts: createResourceGatekeeper(), the urlPattern-to-capability
  chokepoint for creations, applying the same admin disable-set checks as
  getGatekeeperClassFor().
* overseer.ts: the hook implementation (vendor/creatable validation, mint,
  addGatekeeper, submitCreationAction through an ApprovalQueueImpl scoped to
  the new workpiece and the creating chat, with removeGatekeeper on failure);
  GatekeeperRecord.provisional drives a one-shot describe refresh (guarded on
  the URL changing, since an invalidated edit can apply before the creation) in
  applyPendingAction once the creation applies, since nothing else ever
  re-denormalizes resourceTitle/resourceUrl; listConnectableResources
  annotates creatable types.
* ChatInterface.tsx: the six exhaustive tool-display switch cases.
* integration tests: the fixture gatekeeper grows a creatable type with
  provisional URLs and a rejection-kills-the-binding path; new end-to-end
  coverage for fixable retry, pre-approval use, the action card, the
  describe refresh after approval, replay across turns, and rejection.
… scan.

buildCompactionState folded createGadget/createWorktree outputs into the
checkpoint's chatBindings but not createExternalResource, so once compaction
crossed the creation call the binding replay re-establishes from the recorded
output silently vanished. prepareChatBindings' naming chokepoint had the same
gap: the quick-model namer could hand a pasted capsule a name replay would
bind to a created resource. Both now mirror the replay semantics: a structured
output binds, a string (rejection) output only reserves the name.
…owner.

createExternalResource resolved the workspace owner's user DO, so a
collaborator-driven turn enumerated the owner's connected accounts (the
ambiguous-accounts error lists ids and names into the transcript) and created
resources under the owner's credentials. Follow the actor-scoped pattern of
listAvailableBlueprints: the hook now takes the turn's initiator and resolves
their user DO. Owner-initiated turns are behavior-identical. The vendor
listing stays owner-scoped -- it exposes only static vendor classes.
createExternalResource makes the gatekeeper record and its pending creation
action durable immediately, but the tool call reaches the chat log only at
commitAgentStep -- a DO restart mid-turn left an orphan gatekeeper plus an
approvable creation action bound to nothing, and the resumed turn could mint
a duplicate. Mirror the gadget pending lifecycle: stamp
GatekeeperRecord.pending = {chatId} in the same put as `provisional`, clear
it at the step barrier in addChatMessages (same transaction as the message
write), and sweep unstamped markers from reconcilePendingGadgets' schedule
and chat deletion. The sweep keeps a gatekeeper whose creation was actually
applied (approved-action fallback covers a stuck `provisional` from the
best-effort describe refresh); otherwise it rejects the queued actions and
removes the workpiece in one durable step.
- createResourceGatekeeper: check the admin disable-set after the vendor RPC
  against the resolved resource.urlPattern, mirroring getGatekeeperClassFor
  (the vendor is the authority on which type a request maps to).
- applyPendingAction's post-apply refresh: re-read the gatekeeper record after
  the describe() await so a concurrent removeGatekeeper isn't resurrected by
  the stale put, and refresh creationSpec.resourceUrl too so blueprint export
  with suggestValue doesn't export the dead provisional URL.
- ChatInterface: label a rejected creation "Tried to create external resource"
  instead of claiming it was created.
- submitCreationAction doc: ordering is the gatekeeper's responsibility --
  manual approval has no platform-side ordering guard, so the gatekeeper must
  reject applyAction of dependent actions until the creation applies. The
  test-gatekeeper fixture now models that guard like the Google gatekeeper.
…ation failure.

Observations and hooks are stamped approved at creation, so without the type
gate a vendor authorizing an observation before its creation action would fake
an approval injection (and first-encounter delete would silence the real card).
The gatekeeper row is now read only when the state is approved, the only case
that consumes it. The awaitDecision latch a failed submitCreationAction set is
restored to its prior value: the settled action can never be decided, and
suspending the turn would hide {created: false} from the model. Also: the
transcript row now uses isCreatedResourceSuccess (an errored call rendered
"Created external resource" beside its Error badge), and the reap comment no
longer claims the action index is resolved-only (pending records index under
their type too; the state check is load-bearing).
The creation card is where replay injects the user's decision; compacting it
away orphaned the decision, so in any sufficiently long chat the recorded
tool result's "does not exist yet" became the model's permanent last word.
Mirror the pending-connectionRequest rule: replay notes the earliest
still-undecided creation card and findProtectedFromSequence keeps the boundary
behind it. Once the decision lands, the next replay injects it before the
projection is built from the same model messages, so the summary absorbs it
and the card compacts normally. Same trade-off as connection requests: an
undecided creation defers compaction until the user decides.
GOOGLE_DOC_RESOURCE is the first creatable type. createResource() checks the
account holds the doc grant (documents.create needs the write scope that
grant already carries -- no new scopes), mints a provisional-<uuid> document
id, and returns a GoogleDocGatekeeperImpl imbued with creation props.

The facet queues a createDocument action as pending action #1, so the
existing in-order approval rule applies the creation before any queued edit.
Until then the binding simulates over a synthetic empty snapshot with zero
provider traffic (describe(), getMetadata(), and getContent() all answer
locally); sourceMap/bodyEndIndex are safe to fake because materialization
only runs at apply time, when the document exists. Apply calls
documents.create and late-binds provisional-to-real via the kit's
ProvisionalIds; edit actions resolve the real id before touching the API.
Rejecting the creation invalidates every queued edit and marks the binding
dead, so session methods explain instead of simulating against nothing.

A crash between documents.create and the binding write can leak one
duplicate document on a retried approval (the API has no idempotency key and
the edit write-marker protocol cannot cover creation); accepted for now.

The workerd docs suite covers simulation-without-traffic, create-then-edit
ordering, retried-approval idempotency, and the rejection cascade.
Document creation keeps its own action kind, separate from the edits one, so
enabling hands-free creation is a distinct choice -- the case that wants it
is a scheduled task minting a new doc each run, whose queued edits would
otherwise stall in-order behind a creation card every time. Nothing is
auto-applied without the user enabling the createDocument rule.
@ndisidore
ndisidore force-pushed the nathan/create-external-resource-google branch from 6011068 to faee59d Compare September 4, 2026 19:50
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

Preview: pr428-nathan-create-bd0fd285

https://pr428-nathan-create-bd0fd285-router.cloudflare-os-previews.workers.dev

Dashboard · deleted when this PR closes

@ask-bonk

ask-bonk Bot commented Sep 4, 2026

Copy link
Copy Markdown
  • Low packages/gatekeeper-google/src/google.ts:1553: Creation is advertised for every Google Doc binding, including existing, materialized, or rejected documents. This exposes a rule that can never match. Only return CREATE_DOCUMENT_ACTION for an unresolved pending creation.

github run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gatekeeper Changes to a gatekeeper integration

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant