Sandboxed Office Plugin Runtime
Status: Accepted implementation and release contract for protocol and manifest version 1 (2026-08-27).
Tier 2 Office plugins are third-party ZIP bundles created as self packages.
Self packages are private to their owner and skip human review; the only route
to a marketplace audience is the reviewed promotion flow. Both
audiences use the same deterministic scan, upload limits, opaque-origin iframe,
sandbox, CSP, SDK, scope vocabulary, and kind-by-operation capability matrix.
Installation creates a per-user, per-package principal; neither the iframe nor
its bundle receives a session cookie, service credential, provider token, or
raw platform bearer token.
The authoritative contract is exported as
@arinova/shared/office-plugin-contract. The Rust validator independently
enforces the same wire, manifest, path, origin, and scope allowlists at upload.
Any normalization failure rejects the complete install snapshot; it never
partially injects a contribution.
Version 1 Decisions
Section titled “Version 1 Decisions”| Decision | Accepted behavior |
|---|---|
| D6-1 grants | Each install UUID is a distinct principal. Marketplace installs grant read/write access per resource in office_plugin_resource_grants; self installs may additionally use kind-level grants, which always remain intersected with the owner’s current product ACL. Agent ACL tables are never reused. |
| D6-2 conversations | Third-party chatRightPanel contributions are visible only in human-to-agent (h2a) conversations. |
| D6-3 KV | 1 MiB per user/install, 256 KiB per value. Users can export while installed and for 30 days after uninstall; a supervised hourly worker then deletes the retained values. |
| D6-4 contributions | officeNav, chatRightPanel, bounded Office toolbar actions, and bounded command-palette commands are accepted. toolbar:action emits {actionId} and command:action emits {commandId} only into the plugin’s current iframe generation; command launch queues fail closed. Inline message injection, arbitrary groups, global shortcuts, and host-executed plugin actions remain rejected. |
| D6-5 pricing | Marketplace packages may be free or points-priced and require reviewed admission; paid runtime bytes require an active entitled install. Self packages are private, owner-only, zero-priced, and absent from the catalog. |
| D6-6 review | Every version must pass the deterministic scan. Marketplace publication additionally requires explicit human admin approval; a self owner may activate a scan-passed version without human review. |
| D6-7 egress | Resource scopes and network disclosure are separate consent sections. Every origin must be acknowledged as an exact set and can be revoked/restored from plugin settings. Revocation takes effect on the next runtime load. |
| D6-8 create | Create authority is defined by the shared kind-by-operation matrix. The host obtains a five-minute, one-use authorization from the generic {kind} preflight and calls the transaction-bound resource proxy; the proxy, not browser code, validates product ACL, mutates the resource, grants it, records idempotency, and audits atomically. The ticket never enters plugin RPC. |
| D6-9 CSP | Runtime script authority is exactly 'self' plus the SHA-256 hash of the fixed inline SDK. There is no nonce, unsafe-inline, or unsafe-hashes. The SDK runs before author bytes, consumes and clears the one-use bootstrap name, and creates the child-owned channel. Reviewed HTTPS origins still govern forms, images, fonts, media, fetch/beacon and HTTPS/WebSocket connections; WebRTC is disabled before author code. Self-navigation cannot be prevented by a supported CSP directive, so any duplicate load fails the generation closed and requires a user retry. |
| D6-10 S2S | Service tokens are a separate, explicitly issued server-to-server principal. They are not SDK credentials and are never embedded in HTML, launch context, registry descriptors, attachments, or iframe messages. |
Q4 ratifies D6-1 through D6-10 as the v1 baseline. All 14 parsed resource kinds
now have at least one executable operation, but authority remains operation
specific: imageProject:create and outboundEndpoint:{create,update} stay
disabled. C1/Q5 retain the hash-pinned SDK-first, child-created-channel and
generation-fail-closed design above; the Office-plugin LLM purpose/model policy
is owned by track E.
Package Audience And Promotion
Section titled “Package Audience And Promotion”New package creation always persists audience self; a request attempting to
set audience:"marketplace" is rejected. Packages that existed before the
audience migration may already be marketplace. A self package is visible through the
authenticated owner-only GET /api/office/plugins/mine surface and can be
installed only by its owner. Non-owners receive 404 for its detail, install,
runtime, and proxy authority surfaces. The marketplace catalog is always
marketplace-only and never gains an owner visibility shortcut.
An owner may activate a scan-passed self version without admin review. Active
self installs are moved to the newly activated version in the same transaction,
so the install record and every authority surface stay consistent. A
marketplace version still requires the complete submit-review and admin-
approval flow. Promotion is one-way: after a self package has an approved
version, POST /api/office/plugins/packages/{packageId}/promote activates that
approved version and changes the package to marketplace in one transaction.
The transaction disables existing installs for mandatory reconsent, revokes
their resource and kind-level grants and service tokens, and releases the
self-era personal-storage accounting rows. Reconsent reactivates the same
install row, preserving its install identity and KV namespace while applying
the approved version’s exact scopes and origins. Marketplace-to-self demotion
is unsupported because it could strand other users’ installs.
Self and marketplace installs require the same exact scope and independent
egress-origin consent. Self installs may additionally grant a consented,
executable resource kind at scopeLevel: "kind"; access then means the
intersection of that coarse grant and the owner’s current product ACL. The
operation capability matrix and adapter DTO projection still apply. Resource-
and kind-level rows may coexist, and removing the kind row restores the
resource-only result. Granting a kind first runs the same adapter session gate
as a concrete resource grant, so browser-session-only kinds cannot be widened
through a CLI credential. Create replay remains tied to a concrete
scopeLevel: "resource", grantSource: "created" row. S2S authorization
reports a kind grant as {allowed:true, scopeLevel:"kind"} without enumerating
the owner’s resource IDs.
Self and marketplace bundle bytes count against the creator’s personal File Center storage through lifecycle-managed, non-downloadable accounting rows. The free personal allowance comes from the live storage runtime setting, with paid storage units added by the existing billing model. Exceeding quota blocks only a new upload and does not disable an existing install. Self packages retain the five newest versions while never retiring the active version. Marketplace approved and published versions are retained for audit; excess draft or rejected bundles may be retired. Promotion retains the same accounting marker and immutable bundle objects, and package deletion removes every version’s objects and accounting rows. Owner deletion prevalidates every bundle, refunds reserved LLM operations and releases accounting in the relational transaction, commits all relational deletion first, and only then removes external bytes. Retention cleanup follows the same post-commit byte-deletion order and retries previously retired rows until their external objects are gone.
Each install response issues a 15-minute runtime URL and asset-base URL whose
HMAC-SHA256 proof is bound to the install, version, user, and expiry. Runtime
HTML and signed assets are private and no-store; an opaque-origin iframe can
fetch the asset capability without a parent cookie. Unsigned immutable assets
remain public only for free, published marketplace versions. Paid marketplace
and all self assets require the signed install capability. Owners use the
Office-plugin CLI to create a self package, activate or deactivate self
versions, submit a version for review, and run promote.
Identity And Manifest
Section titled “Identity And Manifest”packageId must equal the developer’s OAuth client ID and match
^[a-z0-9]+(?:[.-][a-z0-9]+)*$ at 3–64 characters. The platform issues an
unpredictable UUID installId. Only the trusted UI registry uses
sandboxed:<packageId>; attachment wire types use the reviewed namespace
x-plugin:<packageId>/<subtype>.
plugin.json is strict: unknown fields fail validation. It declares semantic
version, HTML entry, optional local icons/labels, exact requested scopes,
exact HTTPS origins, and at least one allowed contribution. Bundle paths are
relative, slash-normalized, bounded, and cannot contain empty, dot, parent,
backslash, scheme, symlink, prefix-conflicting, or unsupported-extension
entries. ZIP count, compressed size, per-file size and total expanded size are
bounded. HTML cannot supply its own <base> element.
The web normalization layer combines a validated manifest with the trusted install record. It assigns installation order, the workspace group, h2a visibility, runtime URLs and localized fallback labels. A malformed install is isolated from that snapshot rather than aborting normalization for every other install. The observable registry reconciles the surviving server snapshot atomically, and install, upgrade and uninstall notify mounted navigation/panel consumers immediately.
Runtime And SDK
Section titled “Runtime And SDK”The host loads the server-issued
/office-plugin-runtime/<installId>/<packageId>/<version>?playUser=…&exp=…&sig=…
capability in an iframe with sandbox="allow-scripts", an empty Permissions
Policy allow, and no-referrer. The index revalidates the exact active
install through the shared authority predicate and verifies the short-lived
signature; missing, expired, foreign, or stale capabilities are not found. The
iframe receives no session credential. The SDK remains public and short-cache
revalidated; bundle assets follow the signed/private versus free-marketplace/
public split above.
Each generation gets a 256-bit bootstrap token in the iframe name, never in
its URL. The first executable is the exact inline SDK authorized by the CSP
hash. It synchronously copies and clears window.name, creates a
MessageChannel, installs and starts the plugin-side listener, keeps that raw
port inside its closure, and transfers only the host-side port to the parent.
The parent checks the exact iframe window, opaque null origin, protocol,
token, generation, key set, bridge-enabled state, and exactly one transferred
port, then consumes the token before using that port. It never sends a
capability back through the iframe WindowProxy. Re-evaluating the SDK after
the name is empty is a no-op.
All later traffic is accepted only on the accepted port. Timeout, duplicate
load/navigation, plugin error, unmount, or protocol failure synchronously
disables the bridge, marks runtime unready/failed, closes the port/session, and
removes the iframe. Only an explicit user retry creates a new generation and
token. Request concurrency is bounded. Incoming messages, session.handle
input, item mutation input, events, attachments and launch payloads remain
capped at 262144 bytes. Only an outgoing rpc:response has a separate bounded
budget: the serialized RPC result is at most 392192 bytes and the complete
response envelope is at most 393216 bytes. The 1024-byte headroom covers the
protocol, channel token, response type and the maximum 128-character request
ID. Init, event and inbound traffic do not inherit this larger cap. An
oversized result or response fails deterministically; fields are never
silently truncated.
The mutable public SDK URL
/office-plugin-runtime/sdk/v1.js is served with a five-minute
must-revalidate cache policy, not a one-year immutable policy. Versioned
bundle assets remain immutable. The authenticated inline bootstrap continues
to use the same checked-in SDK bytes and automatically derived CSP hash.
A plugin registers once:
ArinovaOfficePlugin.define({ async init(sdk, container) { const page = await sdk.resources.list("doc"); container.textContent = `${page.items.length} granted documents`; }});The v1 surface is:
sdk.resources.list/get/create/update. The host calls generic/sdk/resources/{kind}proxy routes and never calls product REST resources directly. The kind-by-operation matrix below is authoritative. Read/update require current install consent, product ACL, and a live per-resource grant. List enforces the ACL/grant intersection in SQL and uses adapter-owned bounded keyset pagination. Create first obtains a host-only authorization and then commits the ticket, product mutation, created-source write grant, namespaced idempotency record, and audit in one transaction. Replays re-check current consent, grant, and product ACL and return the current DTO, never a retained historical content snapshot.sdk.resources.items.list/get/create/update/delete. These are additive protocol-1 methods;OFFICE_PLUGIN_PROTOCOL_VERSIONremains exactly1. They call/sdk/resources/{kind}/{containerId}/items[/{itemId}]. Container IDs remain UUIDs, while item IDs are non-empty, control-free strings bounded to 256 UTF-8 bytes and are URL-encoded as one path segment. Item authority is the existing container consent scope, container grant (resource or kind level), and current product ACL. There is no item grant table, scope, picker, or consent vocabulary. List/get use the containerreadscope, create usescreate, and update/delete useupdate. Mutations use a container advisory lock, a durable operation ledger, and a request hash/idempotency namespace containing kind, container, operation, and item dimension. Fresh commits run product hooks after commit; replay re-reads safe state or returns a stable delete acknowledgement without replaying hooks.sdk.events.on("resourceChanged", callback). Successful host resource mutations emit only the changed kind so plugins can refresh their already grant-filtered lists without learning ungranted IDs or content.sdk.storage.get/set/delete, with JSON-only bounded KV and serialized quota updates.sdk.llm.generate, billed to the current user plus install principal. The host reserves and settles wallet points with a daily install/user cap, idempotency, bounded input/output and a 30-second provider timeout. Calls go only throughLlmGateway; only a prompt digest is retained. A supervised reaper refunds reservations left behind by process failure.sdk.chat.attach, restricted to the calling package’sx-plugin:namespace. Message bubbles always use a single built-in generic codec/card; they never mount plugin iframes. Clicking the card opens the general plugin route and transfers a validated payload through one-use, five-minute same-tab storage so custom rendering happens only inside the runtime.
Attachment launchContext is message-authored input, not authority. Plugins
must validate it as untrusted data; it never adds scopes or resource grants.
Parse vocabulary and executable authority
Section titled “Parse vocabulary and executable authority”The v1 parser continues to accept 14 resource kinds so existing manifests,
installs, grants, and service tokens remain readable and revocable:
doc, workbook, kanbanBoard, slideDeck, calendar, form, notebook,
mindmap, imageProject, webhook, trigger, cronJob,
outboundEndpoint, and workflow.
The executable contract is a kind-by-operation matrix, mirrored in Rust and TypeScript and checked against a shared golden file:
| kind | read | create | update | v1 projection and limits |
|---|---|---|---|---|
doc | yes | yes | yes | Full bounded document DTO; integer version and expectedVersion; archived documents are hidden. |
workbook | yes | yes | yes | Container list and whole JSONB blob get/replace; 256 KiB proxy payload cap; blob writes require expectedVersion, while rename-only writes do not bump version; formula ranges are bounded by the workbook row/column and aggregate-cell limits before evaluation; archived workbooks are hidden. |
kanbanBoard | yes | yes | yes | Columns, cards, labels, and card-labels are read-only nested projections; update is owner-only board metadata; no version token; archived boards are hidden. |
slideDeck | yes | yes | yes | Slides are read-only nested projections with deterministic content truncation; create adds one blank slide; update is deck metadata only; no deck version; web session required. |
calendar | yes | yes | yes | Container only; native calendars can be created and owner-updated, device mirrors are read-only, events are excluded, and no version token exists. Item exceptions remain scoped to the granted calendar even when recurrence exceptions were moved across calendars. |
form | yes | yes | yes | Active fields are read-only nested projections; create/update are container metadata only; draftGeneration is the concurrency token; public share tokens, responses, and revision history are excluded. |
notebook | yes | yes | yes | Notebook container plus at most 50 note previews; notes remain read-only because their content is CRDT-managed; metadata update has no version token. |
mindmap | yes | yes | yes | Nodes are a bounded read-only nested projection; create adds a root node; update is metadata only; archived maps are not writable; web session required. |
imageProject | yes | no | yes | Read includes typed canvas/layer structure but no image bytes; update can rename only and uses documentRevision; create/revision upload is unavailable; web session required. |
webhook | yes | yes | yes | Redacted metadata only; create forces a no-target configuration and never returns a signing secret; update is metadata only; no version token; web session required. |
trigger | yes | yes | yes | Redacted metadata; create is constrained to grant-backed source containers and rejects calendar sources plus unsupported event types; update renames only; no version token; foreign IDs require active install grants. Plugin-created instructions carry third-party provenance. |
cronJob | yes | yes | yes | Prompt, agent, conversation, workflow, dedupe, and error fields are excluded. Create is limited to bounded allowlisted-Agent wakeups; update can change only name/enabled. A name change transfers an existing concrete created grant with its metadata, but kind-only authority never mints one. No version token; revoked plugin/Agent authority pauses before the next fire. |
outboundEndpoint | yes | no | no | Redacted endpoint metadata only. Non-human mutation remains architecture-banned; destinations, headers, templates, secrets, tokens, and deliveries are excluded. |
workflow | yes | yes | yes | Read returns metadata plus structural graph only; create allows inert draft sleep/notify/allowlisted-agent graphs; update is name/description only. Plugin routes cannot activate, run, pause, or delete. User activation and executor claim revalidate plugin provenance; message/delivery remain forbidden. |
The item layer is independently narrowed from container operations:
| kind | items.list | items.get | items.create | items.update | items.delete | item contract |
|---|---|---|---|---|---|---|
notebook | yes | yes | note | note | note | Cursor-paginated bounded summaries; full single-note read; version-CAS writes use the five-step note writer (row guard, Ydoc clear, update purge, post-commit room mirror, cache invalidation). |
calendar | yes | yes | single event | event/occurrence | event/occurrence | List requires a positive window of at most 366 days and expands RRULEs in-transaction. Update/delete require recurrenceScope=this, this_and_future, or all plus root expectedUpdatedAt CAS. Device-mirror calendars are read-only. |
kanbanBoard | cards + columns | card or column | card | card | card | Card lists omit descriptions; single get returns full bounded content. Columns are positive-allowlist, bounded, and read-only. Card movement and ordering are transaction/CAS guarded. |
slideDeck | yes | yes | slide | slide | slide | Lists return summaries only. Single reads/mutations return one complete slide, enforce the product 256 KiB content limit, and preserve version/checkpoint semantics. Asset references never yield asset bytes. |
mindmap | yes | yes | node | node | node | Cursor/subtree list summaries omit label styles and SQL-bound legacy sort keys to 255 Unicode characters. Single get is complete within the result cap. Update/delete require root expectedUpdatedAt CAS; replay never publishes events. |
workbook | no | no | no | cell range patch | no | The only item ID form is sheet:<sid>. A strict range patch requires baseVersion, locks and compares the workbook version, preserves untouched cells, and increments once. |
form | yes | yes | no | no | no | Bounded field summaries omit options, validation, and help text. Single get returns the complete safe field when it fits the result budget; oversized legacy fields fail without truncation. |
doc, imageProject | no | no | no | no | no | No item API. |
webhook, trigger, cronJob, outboundEndpoint, workflow | no | no | no | no | no | Automation data is not exposed as structural items. |
Every item request object is strict. List DTOs are separately bounded positive projections and cursor-paginated; legal maximum pages fit the 262144-byte list budget instead of relying on a whole-page payload error. Full single-item DTOs exclude owner/principal identity, shares/public flags, comments, responses, embeddings, sync/external IDs, secrets, CRDT bytes, foreign asset bytes and authorization rows as applicable. Calendar, slide, form, notebook, Kanban and Mindmap single reads do not silently truncate safe fields; a legacy row beyond the applicable outbound result budget receives a deterministic payload-too-large error.
Notebook note bodies remain behind the existing CRDT collaboration boundary. The resource proxy never reads, writes, or fabricates notebook CRDT updates: it exposes only the notebook container and bounded note metadata previews, so content changes continue to flow through the collaboration protocol and its own authorization, ordering, and persistence rules.
An iframe request for a disabled kind-operation pair fails before scope
evaluation with RESOURCE_KIND_DISABLED. HTTP/S2S endpoints return 403
OFFICE_PLUGIN_RESOURCE_KIND_DISABLED for the same pair. Unknown kinds are
ordinary 404s. Existing authority revoked by the expansion migration remains
display/revoke-only until a package version requests the now-executable scope
and the user renews consent; enabling an operation never revives dormant grants
or service tokens.
TypeScript authors can import OfficePluginSdk, OfficePluginDefinition,
manifest schemas, resource kinds and attachment helpers from the shared
contract export. Each adapter owns a strict plugin-specific input and DTO
projection; product REST structs are never serialized wholesale.
Attachment Trust Boundary
Section titled “Attachment Trust Boundary”An Office-plugin attachment is trusted for human display but untrusted for
recipient selection, URL/identifier side effects, agents, LLMs, and semantic
learning. On ingress the server removes client-authored provenance keys and,
when either raw or canonical metadata contains a reserved plugin signal,
stamps _agentProjection:{kind:"office_plugin_attachment",version:1}. A
normal insert returns the metadata actually persisted by PostgreSQL before
dispatch; an idempotent duplicate remains acknowledgement-only and is never
redispatched.
The shared Rust projector inspects only reserved top-level/resource paths; it does not recursively scan arbitrary payload strings. A complete, matching canonical envelope with unambiguous framing may expose the user caption as:
[plugin attachment]<caption>Raw/persisted disagreement, draft/malformed envelopes, or historical
derivative markers expose only [plugin attachment] plus fixed safe metadata.
Forwarding propagates the server marker even when card metadata is otherwise
dropped. Every id-based queue, cron, regenerate, Action, Autopilot, official
automation, and worker re-entry rereads the source row and fails closed if the
row or database is unavailable. Human message cards, broadcast, export, and
explicitly human search may retain the raw display copy.
Plugin attachment rows are excluded from embeddings, memory learning,
conversation rollups, chat-reasoning task flows, auto-skill creation/promotion,
agent/slash search, and agent-audience ambient retrieval. Catch-up and Office
summaries, translation, simulation, current/history prompts, direct agent
message reads, Autopilot, official agent replies, and webhooks receive only the
projection. Generated messages persist server-owned triggerMessageId and
fixed eventOrigin provenance so typed historical lineage never depends on a
caption or arbitrary nested UUID.
Consent, Grants, And Revocation
Section titled “Consent, Grants, And Revocation”Installation and every upgrade require the exact reviewed scope list and a separate exact-origin acknowledgment. Upgrade consent also shows the concrete resource grants that will remain in force; a write grant shown after a scope downgrade is represented as the surviving read grant. Disabled operation scopes are never offered for new consent and block a newly reviewed package. A host-owned, per-kind settings picker lists the current user’s product resources without exposing that unfiltered collection to the plugin, then grants selected IDs at read or write level. Read-only kinds offer only read grants; product ACL is rechecked in the same transaction as grant mutation. Removing a scope during an upgrade revokes incompatible grants and S2S tokens. Uninstall atomically disables the principal, revokes resource grants and S2S tokens, blocks runtime/SDK authorization, and starts the 30-day KV export window.
Automation scopes require capability-specific disclosure in addition to their machine-readable scope labels. Webhook create can establish a public Internet address, although the plugin receives no signing secret and the created hook has no target. Trigger create can install a persistent owner-agent wake-up rule with plugin-authored instructions; uninstall revokes plugin authority but does not delete that trigger, so the user must cancel it in Office. Cron-job create can schedule only an owner Agent explicitly present in the install’s Agent allowlist. Create, update, replay, and each scheduler fire revalidate that allowlist and the shared install predicate. Revocation, uninstall, or loss of install authority pauses the job before its next fire.
Workflow authority is narrower. Read exposes names, status, and step structure,
never step content, targets, or message text. Create produces an inactive draft
whose closed step set is exactly sleep, notify, and agent; every Agent must
be owner-owned and present in the install’s Agent allowlist. message and
delivery remain forbidden, and update can change only name and description.
The plugin API cannot activate, pause, run, or delete a workflow. When a user
activates it and whenever an executor claims a run, the persisted plugin origin,
shared install predicate, Agent allowlist, and closed step set are revalidated;
revocation fails the run before any step dispatch. Outbound-endpoint authority
is read-only and exposes redacted metadata, not destinations, authentication
material, request templates, or secrets.
Network revocation leaves the reviewed origin declaration visible for audit
but generates a no-egress CSP on the next runtime document. The policy covers
navigate-to, form-action, img-src, font-src, media-src, and
connect-src; WebSocket forms of approved HTTPS origins are derived by the
host. Frames, objects and manifests are denied. Runtime assets are immutable
by version; an approved old version remains readable only while an active
install is pinned to it, so approved-but-never-published versions are not
publicly exposed.
Server-To-Server Tokens
Section titled “Server-To-Server Tokens”Users explicitly issue a named token for a subset of the install’s resource
scopes and a lifetime of 1–90 days. The raw ari_op_s2s_… value is shown once;
only its digest and a display prefix are stored. Tokens are independently
listable/revocable and become invalid on expiry, uninstall, user deletion or
ban, or when an upgrade removes any carried scope. For a suspended marketplace
package, the shared legacy policy keeps only already-entitled active installs
(and their still-valid tokens) usable while new sale/install admission remains
closed.
The dedicated endpoints are:
GET /api/v1/office-plugin-s2s/whoamiPOST /api/v1/office-plugin-s2s/authorize
They accept Authorization: Bearer <service-token>, resolve only an
office_plugin_service principal, and evaluate the token scope plus the same
install resource grants. They do not accept browser channel tokens or
sessions, and the browser SDK endpoints do not accept S2S credentials. New
tokens can contain only executable (kind, operation) resource scopes from the
matrix above. A stored disabled scope remains visible/revocable, but
authorization returns 403 OFFICE_PLUGIN_RESOURCE_KIND_DISABLED.
LLM Ledger Rollout
Section titled “LLM Ledger Rollout”Office plugin LLM ledger separation has an additional mandatory two-revision
deployment barrier. The E-compat/E-switch artifact, fleet-drain, catalog
preflight, and rollback contract is documented in
office-plugin-llm-ledger-rollout.md.
Neither an ordinary one-artifact rolling release nor an environment toggle is a
valid way to begin emitting the Office-specific ledger source.
Publishing And Rollback
Section titled “Publishing And Rollback”Developers manage one Office plugin package from the matching OAuth
application in /developer. The OAuth client_id is the package ID, so an
application cannot create a second package. The console creates private
(self) packages only, validates the root plugin.json in the browser,
rejects bundles over 20 MB before upload, shows upload progress and scan
findings, and lists every version with its review and active state. A self
draft exposes submit-review as the start of the one-way marketplace promotion
flow; an approved self version exposes promote.
Private packages can publish a scan-passed version directly. Marketplace packages must submit a scan-passed draft for administrator review before the owner can publish it. Approved older versions remain available for rollback. Marketplace owners can configure the bounded points price after promotion.
Administrators review pending marketplace versions from
/admin/office-plugin-review, including the declared network origins,
requested scopes, complete manifest, deterministic scan report, and developer
contact. Rejections require notes. Approval and rejection commit first, then
best-effort delivery creates one admin_review in-app notification for the
developer and emits its WebSocket event. The persisted destination is
/developer; no review email is sent. A delivery outage is logged but never
rolls back the durable review decision.
Administrators may suspend or restore a package through
POST /api/admin/office/plugins/packages/{packageId}/suspend and /unsuspend.
Each state transition commits both the immutable admin audit and the plugin
event (package.suspended or package.unsuspended) in the same transaction;
replays are idempotent. Unsuspend requires a published active version and is
forbidden for a purge-tombstone owner. Suspension removes catalog, purchase,
and new-install admission while the shared legacy predicate preserves existing
entitled marketplace installs.
The repository CLI remains a supported, cookie-authenticated interface for initialization, deterministic build, local preview, publish, and rollback:
pnpm office-plugin init ./my-plugin --id <oauth-client-id>pnpm office-plugin build ./my-pluginpnpm office-plugin version 1.2.0 ./my-pluginpnpm office-plugin preview ./my-plugin --port 4176ARINOVA_SESSION_COOKIE='…' pnpm office-plugin publish ./my-plugin --api https://api.exampleARINOVA_SESSION_COOKIE='…' pnpm office-plugin rollback <package-id> --version-id <uuid> --api https://api.exampleThe first marketplace publish invocation uploads and submits the version to
review. After an admin approves it, running the same command again resumes the
existing version and activates it; the CLI does not bypass or poll the human
gate. API targets must use HTTPS, except loopback development URLs, and
redirects are refused so the environment-only session cookie is not forwarded
elsewhere. These catalog routes deliberately retain ordinary cookie AuthUser
authentication instead of requiring a browser-only web-session marker, which
preserves existing CLI automation.
Builds are deterministic stored ZIPs. Credentials are read only from the
environment, never accepted as CLI arguments. Upload runs strict validation
and a deterministic source/listing scan. Runtime entries reject executable
inline script, on* handlers, and javascript: URLs; external script paths
must resolve to the reviewed bundle inventory, while genuine non-executable
data blocks remain valid. The tokenizer uses browser-compatible HTML parsing,
and CSP remains the enforcement boundary. Submission enters a human review
queue; approval and owner publication are distinct mutations with audit rows.
Publishing changes the catalog pointer but existing installs remain pinned
until their users review the new version’s scopes/origins and upgrade.
Rollback can activate any previously human-approved version. The first-party
no-network dogfood package is in examples/office-plugins/insights.
Before deploying the hash-only script policy, audit every stored version—not only published versions—and retain the schema-versioned report:
cargo run --bin office_plugin_inline_compatibility_audit -- \ --environment production \ --output office-plugin-inline-compatibility.production.jsonAny incompatible version not already rejected/retired, or any incompatible
version with an active install, is blocking. Do not rewrite immutable approved
bytes or grandfather a nonce. The publisher must ship an externalized new
version and complete upgrades, or the operator must explicitly disable it and
notify affected users before the count can reach zero. An unreadable,
non-UTF-8, or invalid file inventory always increments
auditFailureVersionCount and makes the command fail, even for a retired
version, because compatibility was not actually established.
Resource Proxy Cutover Runbook
Section titled “Resource Proxy Cutover Runbook”The completed route set contains only these generic browser resource routes:
POST .../sdk/resources/{kind}/create-authorizationGET/POST .../sdk/resources/{kind}GET/PATCH .../sdk/resources/{kind}/{resource_id}
Migration 664 created office_plugin_resource_operations; migration 668
removes the doc-only constraints, widens nullable result_version to BIGINT,
and revokes dormant non-doc grants and tokens. Operations store only the request
hash, resource identity, optional result version, and completion time; they
never store response/content snapshots. External idempotency keys are
deterministically namespaced by kind before non-doc persistence so identical
keys in different kinds remain independent without rewriting legacy doc replay
identity. The older
office_plugin_created_resource_operations table is a read-only tombstone.
The operational cutover order is part of the security boundary:
- Deploy the fixed proxy and preflight while the legacy generic authorize and created routes are temporarily present.
- Deploy the new host, verify it has no legacy fallback and makes no direct product-resource call, then observe legacy traffic by route and operation.
- Deploy the cutoff build, where every legacy authorize operation returns 410
OFFICE_PLUGIN_HOST_UPGRADE_REQUIRED. Remove every older ticket issuer from the load balancer, drain/terminate its in-flight requests, and probe all four operations on every live instance before recording immutableT_cutoff. - Wait the full five-minute ticket TTL from
T_cutoff; only then remove the legacy created route. Remove the already-410 authorize route after its compatibility window. Regenerate both route registries after each change.
A request authorized before T_cutoff may have detached from the old host and
perform one delayed user-owned product mutation; create can therefore leave an
orphan doc. The cutoff bounds that population, and an expired ticket or removed
created route cannot create/restore a plugin grant. This residual is not a
claim of zero product mutation.
Attachment Projection Rollout And Remediation
Section titled “Attachment Projection Rollout And Remediation”Third-party plugin unfreeze requires an evidence-bearing rollout, not merely a code deployment. The combined operator sequence, including migration 670’s two-phase webhook validation and the LLM ledger barrier, is the third-party unfreeze runbook:
- Pause memory, skill, rollup, chat-reasoning, task-flow promotion, Autopilot, and official-automation producers. Deploy every HTTP/background/queue gate and verify the reviewed source-contract inventory.
- Drain old instances plus agent streams, Actions, extractors, auto-install,
Autopilot, and official runs. Record
T_projection_cutoffonly when all counts are zero. Preserve a JSON evidence file with schemaarinova.office-plugin-attachment-projection-cutoff/v1, the matching environment/timestamp,workersPaused,allInstancesOnProjectionBuild,oldInstancesDrained, andsourceScanReviewedset to true, and zeroinFlightcounts foragentStreams,actions,extractors,autoInstall,autopilot, andofficialAutomation. - Run the bounded default dry-run, review completed Action and unattributed
message candidates, and record each disposition as
safe,quarantined, orirreversible-reviewed. - Apply with a path that does not already exist. The tool creates and fsyncs an immutable before-state manifest before mutation. Never overwrite it.
- Wait at least 600 seconds for old dispatch items and 30 seconds for the
agent retrieval cache (or use the existing namespace invalidation), then
run a second dry-run. Only its zero-active report may set
readyForThirdPartyPluginUnfreeze=true; only then resume workers.
cargo run --bin office_plugin_attachment_projection_audit -- \ --environment production --cutoff "$T_PROJECTION_CUTOFF" \ --cutoff-evidence cutoff-evidence.json \ --dispositions dispositions.json --output audit-before.json
cargo run --bin office_plugin_attachment_projection_audit -- \ --environment production --cutoff "$T_PROJECTION_CUTOFF" \ --cutoff-evidence cutoff-evidence.json \ --dispositions dispositions.json --apply \ --manifest before-state.json --output audit-apply.json
cargo run --bin office_plugin_attachment_projection_audit -- \ --environment production --cutoff "$T_PROJECTION_CUTOFF" \ --cutoff-evidence cutoff-evidence.json \ --dispositions dispositions.json --output audit-final.jsonThe audit uses the production classifier and follows only typed, validated
edges: top-level triggerMessageId, forwarded originalMessageId,
Autopilot/official run foreign keys, and the exact successful message
send/forward Action result. It marks cutoff-era descendants as
office_plugin_attachment_derivative, clears only rebuildable message caches,
rejects pending memory/candidates, archives derived active memories, disables
created skills, and clears unedited Office summaries. It never deletes the
source envelope, message, task flow, memory, or skill. Because task flows lack
message provenance, every cutoff-era chat_reasoning flow for an affected
agent receives the reversible last_extracted_at='infinity' sentinel; the
before-state manifest lists that deliberate over-quarantine and restore data.
Pending confirmation remediation atomically changes confirmation
pending→rejected, Action call requires_confirmation→cancelled, the message
action state, and its active notification, using fixed error
OFFICE_PLUGIN_PROJECTION_QUARANTINED. Races and already completed Actions are
not overwritten or reversed. Edited Office briefs, legacy agent messages with
no typed provenance, URL fetches, webhooks, and external mutations are manual
review/candidate inventory only; the report must never describe them as
precisely attributed, withdrawn, or rolled back.
Account Deletion Limitation
Section titled “Account Deletion Limitation”The canonical deleted_user_purge job now owns Office Plugin account/GDPR
cleanup. It locks and prevalidates the complete package/install/OAuth graph,
suspends retained marketplace packages without mutating another user’s
install, settles reserved LLM operations with source-derived refunds, deletes
the target’s install authorities and KV, scrubs audit actors, and releases
package/OAuth blockers before final user deletion. Immutable bundle objects are
deleted only after every relational statement, including the final user
DELETE, has succeeded and immediately before commit, so an earlier rollback
cannot leave live version rows without bytes. The
account deletion SOP is now an
exception-investigation and retry procedure, not the normal deletion path.
Adversarial Review Matrix
Section titled “Adversarial Review Matrix”| Attempt | Enforcement/evidence |
|---|---|
../, absolute, scheme, symlink or ZIP-bomb path | Independent CLI/Rust path validation, count/size/expanded-size limits and bundle tests. |
| Manifest injects order/group/toolbar/foreign wire type | Strict manifest schemas and all-or-nothing trusted normalization tests. |
| Runtime is blocked by global headers or leaks a referrer | /office-plugin-runtime/ is exempt from global XFO while its own frame-ancestors remains authoritative; the middleware preserves runtime Referrer-Policy: no-referrer. |
| Iframe steals a session or service token | Opaque allow-scripts sandbox, authenticated owner-only index, credential-free public assets, no credentials in SDK/runtime, no-referrer, and a distinct S2S extractor. |
| Inline/remote author script gains bootstrap authority | script-src 'self' plus the exact SDK SHA-256 only; no nonce/unsafe source. The tokenizer rejects executable inline code, handlers, and JS URLs before review. |
| SDK fetch fails before token removal | Bootstrap is a fixed inline block, so there is no fetch dependency; it synchronously clears window.name before any author executable. |
| Sibling/new document races or replays the handshake | Exact iframe source/origin/protocol/token/generation/key checks, one consumed token and one transferred child-created host port. Navigation destroys the child-held port; the old token cannot create another one. |
| Plugin navigates itself repeatedly to exfiltrate data | The first pre-navigation disclosure remains a documented single-navigation residual; duplicate load synchronously disables and unmounts that generation and never auto-retries. |
| Forged install ID/scope in RPC parameters | Principal never appears in RPC params; the host closes over the normalized install and every server SDK route rechecks user/install status and consent. |
| A newly executable operation revives dormant authority | Migration 668 revokes active non-doc grants and affected service tokens; operation-level guards, package versioning, and renewed user consent are required before new authority exists. |
| A partially enabled kind uses a disabled operation | One Rust/TypeScript matrix guards manifest, consent, service-token issue/authorization, grant PUT, browser host, RPC parsing, and proxy routing; imageProject:create and outbound writes remain 403. |
| Create scope self-grants an existing resource | Kind-generic host-only ticket plus install lock, product create ACL, adapter transaction, created-source grant, namespaced operation record, and audit in one transaction. |
| Payload references an ungranted foreign resource | The adapter checks the active (install, foreign kind, foreign id) grant inside the same transaction and returns OFFICE_PLUGIN_CROSS_RESOURCE_GRANT_REQUIRED; agent-principal checks are not reused. |
| Idempotency replay leaks stale/deleted content or repeats mutation | Deterministic normalized request hash and lock order serialize replay/revoke; replay revalidates current consent, product ACL, and grant and reads the current DTO. No response snapshot is retained. |
| Reuse stale port after reload/uninstall | Generation teardown closes the port; server authorization independently requires the signed, unexpired capability and shared active-install predicate. |
| KV injection/quota race | Strict key grammar, JSON body, 256 KiB value cap, per-install advisory lock and 1 MiB aggregate check. |
| Data exfiltration through fetch, beacon, WebSocket, WebRTC, image or form | Separate exact-origin consent plus generated CSP coverage; no consent yields no external network directive, and the SDK disables WebRTC before author code. Self-navigation is handled by the fail-closed generation residual above. |
| Attachment title injects mentions, URLs, identifiers, prompts, or workflows | Server-owned marker plus the shared projector runs before recipient/network/agent/LLM sinks; semantic/learning surfaces exclude the row. Human display remains raw. |
| A queued or historical copy bypasses attachment projection | Source-id re-entry rereads PostgreSQL and fails closed; typed provenance and the cutoff audit quarantine legacy descendants and derived state. Missing proof keeps third-party unfreeze blocked. |
| Attachment registers a dynamic message codec/iframe | One host codec validates package ownership and bounded payload; message list renders only a generic card. |
| LLM failure leaks provider token or charge | Iframe has no token; LlmGateway owns provider routing, reserve/settle is idempotent, timeout paths refund, crash reaper refunds stale reservations. |
| Paid, self, or approved draft assets are guessed | Paid and self assets require the install-bound signed capability. Unsigned public assets are limited to free published marketplace versions; an approved historical version is reachable only through an active pinned install capability. |
Run the shared/Web focused tests, cargo test office_plugin --lib, CLI tests,
locale/static checks, route inventories, SQL/migration checks and documentation
check whenever this contract changes.
Build a7f47a5ca54ddcf7806cd48b81ce1b9827042766