Plugin API v1 reference

Petti 1.4 · Plugin API v1 · Experimental

The complete implemented contract for Petti 1.4.0. Experimental APIs and unavailable capabilities are labeled explicitly. Start with the quickstart before using this reference.

This page is generated from the maintained API contract, not a second hand-maintained method list. Code blocks describe API shapes; some are callback fragments, not independently installable packages. Download the Markdown contract for offline reading.

API reference contents (46 sections)

Status: experimental plugin APIs, 2026-09-30. Commands, bounded text/binary previews, read-only inspectors and optional workspaces/jobs, selected-file import/export, bounded database reads/DML and explicit new-app creation/templates, owned-app upgrades, exact-schema adoption and bounded read-only/reviewed write jobs are implemented within the limits below. Bundled and eligible local user packages execute only with explicit consent; user execution also requires the signed isolated helper. Reviewed table/database actions, typed workspace record forms, native composed pages/trees and validated broader app migrations are implemented below. Explicit command file/clipboard grants, typed grid selection, scoped change subscriptions and configured cross-package templates are implemented below. Native streaming transfers and owned-App SELECT materialization are implemented below. General schema mapping, unbounded JavaScript streaming jobs and automatic workflows remain unavailable.

Audit: swift Tools/check-plugin-api.swift checks all runner registries, manifest permission names and JavaScript example syntax without executing examples. Runtime guarantees are exercised by the full source/packaged test suites and the linked safety matrix; syntax checks alone are not behavior verification. Re-run these checks whenever the public boundary changes.

Current public surface and callback scopes

This document describes the JavaScript wire API, not every Swift method used by Petti's native UI. APIs remain experimental. Local user packages require the signed isolated helper, explicit digest-bound enablement and declared permissions; installation never executes code. Registration is declarative; enabling a plugin never authorizes an automatic mutation. Callback capabilities are narrower than a package's complete permission list.

Registry / scopeCallback and capabilities
registerCommandrun(context); permission-checked queries/metadata/events/transaction, typed selection, explicitly shared file/clipboard snapshots, plugin storage and logs
registerCellPreviewerpreview(input); bounded text/BLOB value only, text/optional tree result; no database/I/O context
registerInspectorrun(context, fields); read/schema methods and progress; returns table/tabs/metrics/chart or native page
registerTableActionrun(context); selected table name and read/schema methods; reviewed write mode also permits change observation, but returns inert statements rather than executing writes
registerDatabaseActionrun(context); read/schema methods; reviewed write mode as above; no selection
registerImporterparse({text}) for small custom formats OR nativeFormat for host-owned whole-file transfer; no callback database access
registerExporterrender({columns,rows,truncated}) for bounded previews OR nativeFormat for host-owned whole-table transfer; no callback database access
registerWorkspaceDeclarative matching/pages/forms/job references; matching performs no implicit adoption or writes; View Raw Database remains native
registerAppDeclarative ordered schema migrations; host-only create/adopt/upgrade review; no raw SQL migration callback
registerTemplateDeclarative local dependencies/configuration/sample seeds; host-owned new-file publication
registerJobrun(context, fields); read/schema/events, progress/logs; write mode returns reviewed statements OR owned-App materialization; no direct transaction, storage, file or clipboard access

Permission, size, lifecycle and SQLite enforcement remains host-side even if a method exists on the JavaScript context. See PLUGIN_SAFETY_VERIFICATION.md for the test matrix. Runnable examples are the bundled packages in Resources/Plugins; their registration and callbacks run through the same runner/permissions as this contract. All APIs under “Planned, unavailable” are intentionally not promised.

Package and approval

A .petti-plugin directory contains manifest.json and its UTF-8 JavaScript entry:

{
  "id": "com.petti.json-tools",
  "name": "JSON Tools",
  "version": "1.0.0",
  "apiVersion": 1,
  "type": "extension",
  "entry": "main.js",
  "permissions": ["selection.read", "clipboard.write"]
}

IDs use lowercase reverse-domain segments (letters/digits/hyphens). Versions require numeric major.minor.patch without prerelease/build suffixes. Categories extension/tool/workspace/app are metadata; commands, read/reviewed-write table/database actions, structured/text/binary cell previewers and native read-only inspector pages are implemented. Entry paths must be contained relative .js paths. Traversal/symlink escape, oversized/nonregular files, duplicate IDs and unsupported API versions are rejected. Permissions are selection.read, clipboard.read, clipboard.write, database.read, database.events, schema.read, database.write, file.readSelected, file.writeSelected and storage; other requests fail validation. Network access remains unsupported.

Bundled packages: Petti.app/Contents/Resources/Plugins/. User discovery: ~/Library/Application Support/Petti/Plugins/. Opening a database does not scan/run plugins. Tools → Command Palette (Cmd-Shift-P) triggers discovery. Limits: 256 directory entries/root, 64 valid candidates, 16 KiB manifest, 128 KiB script. Malformed packages appear with errors. Duplicate IDs disable all conflicting candidates, including bundled/user collisions.

Bundled plugins start disabled. Enable shows permissions; approval/enabled state is stored in Application Support/Petti/plugin-approvals.json, tied to a digest of manifest/source. Changed content requires approval again on discovery. Disable removes commands, cancels execution and revokes result-copy permission. This is local approval, not publisher signing or cross-process revocation.

Command registration — implemented

petti.registerCommand({
  id: "format-json",
  title: "Format Selected JSON",
  run(context) {
    if (context.selection.text === null) throw new Error("Select one loaded text cell first.");
    return {title: "Formatted JSON", text: JSON.stringify(JSON.parse(context.selection.text), null, 2)};
  }
});

Lifecycle and bounds — implemented

PluginManager serializes installed state. PluginRuntime sends value-only JSON to a short-lived QuickJS interpreter helper (engine-only 2026-06-04, no OS module or CLI). No app/database handles cross the boundary. Request/response maximum 512 KiB, ordinary-invocation wall deadline three seconds, child CPU limit three seconds soft/four hard. Registered jobs instead receive 30 seconds wall / 15 seconds soft and 16 seconds hard CPU. Native transfer/materialization phases use separate bounded host deadlines documented below. Cancellation/disable kills the child. Unexpected exit, malformed output, missing helper, JS errors and timeout are surfaced; later executions recover independently.

Manager generations reject revoked results; the palette separately rejects close/reload/replacement results. Requests, replies and acknowledged events use inherited anonymous pipes with bounded length-prefixed frames. No shared temporary IPC files or plugin-chosen paths exist. Last bounded errors appear under Installed plugins. Commands/jobs have bounded in-memory structured logs; committed mutation outcomes have the separate native persistent history described below. Arbitrary persistent plugin logs are not an API.

The packaged helper is signed with App Sandbox and no file/network exceptions. Its JavaScript runtime uses a fixed 64-MiB mmap allocator arena, including object/string/typed-buffer/Promise allocations. Stack and bounded native bridge buffers are additional memory; the physical-memory watchdog is separate. User execution fails closed unless the host-owned helper has a valid strict signature, versioned identifier and sandbox entitlement. Reserved bundled IDs cannot be supplied by user packages. Unsigned development helpers execute genuine bundled packages only. JavaScript numbers can round large JSON integers; formatted output is interpretive and never edits source data.

Database reads — experimental implemented

The host binds context.database to the currently open database session. It is absent without a database or a declared database.read/schema.read/database.write/database.events permission. No path, raw SQLite handle or session selector is exposed.

petti.registerCommand({id: "find-table", title: "Find table", async run(context) {
  if (!context.database) throw Error("Open a database first.");
  const result = await context.database.query(
    "SELECT name FROM sqlite_schema WHERE type = ? ORDER BY name",
    [{type: "text", value: "table"}]
  );
  return {title: "Tables", text: result.rows.map(row => row[0].value).join("\n") +
    (result.truncated ? "\nResults truncated." : "")};
}});

Database Tools uses only these public methods for List Tables and Show Schema. Tests cover typed reads, permissions, write/PRAGMA/file denial, size/deadline limits, WAL committed visibility, revocation/replacement, actual helper IPC and locked Workspace/native palette execution.

Transactions — experimental implemented, restricted DML

await context.database.transaction(statements) requires database.write in the approved manifest, a native confirmation for this command invocation, the current database unlocked with a valid editing license, and the same live database session. Enabling a plugin does not mutate data. A helper cannot provide its own approval, policy ticket, database path or SQLite handle.

const result = await context.database.transaction([{
  sql: 'UPDATE "settings" SET "value" = ? WHERE "key" IS ? AND "value" IS ?',
  parameters: [
    {type: "text", value: "dark"},
    {type: "text", value: "theme"},
    {type: "text", value: "light"}
  ],
  expectedChanges: 1
}]);

Selected text-cell metadata

With selection.read, context.selection.cell is null or {table, column, identityColumns, identityValues, text} for one loaded editable Data-grid TEXT cell. Keys use the exact tagged representation. The native host refuses generated/deferred/unsupported identity cells. Selection is captured when Commands opens; it can become stale. Plugins must include observed values in update predicates. This API is not a query-result or multi-cell selection API.

The bundled JSON Editing plugin exercises only the public APIs. Compact JSON in Selected Cell validates syntax, removes whitespace outside strings, and writes one optimistic update. It preserves numeric spellings (including 64-bit integers) and duplicate keys rather than parsing and reserializing them. Read-only JSON Tools remains separately enabled and cannot write.

Cell previewers — experimental implemented text and binary slice

petti.registerCellPreviewer({
  id: "text-details",
  title: "Text Details",
  storageTypes: ["text"],
  preview(value) {
    return {title: "Text Details", text: "Characters: " + value.text.length};
  }
});

Verification: PluginPreviewerTests exercise the real helper/registry, permissions, duplicate/type validation, bounded input/output, absent capabilities, timeout/cancellation/disable/recovery and the actual bundled outline. PluginPreviewUITests exercises the native menu/model output, fallback on invalid JSON, disabled/closed output disposal, oversized-text refusal, copy permission and light/dark layout. It accepts PETTI_PLUGIN_TEST_BUNDLE to exercise packaged resources/helper. The host Swift changes() stream is internal lifecycle plumbing, not a JavaScript database-change event API.

Embedding Inspector — implemented interpretation, not detection

Enable Embedding Inspector in Tools → Command Palette → Installed plugins. It registers two BLOB previewers: Interpret as Float32 (Little Endian) and Interpret as Float32 (Big Endian). Open a BLOB with Space/Preview Cell, then choose the byte order under Plugin Preview. Matching previewers alone appear in that menu; none execute automatically. Enabling this preview-only plugin does not add a command or change the database.

The plugin decodes the public base64 payload into a bounded Uint8Array and uses DataView.getFloat32. No native decoder or private application API is used. Nonempty lengths divisible by four yield component count (candidate dimension), finite/NaN/+Infinity/-Infinity counts, finite min/max/mean/L2 norm and the first 16 component values. Statistics exclude non-finite components and say so. The output explicitly does not identify an embedding; ordinary bytes can also represent floats. Encoding/byte order must be confirmed with the originating application. Empty/misaligned inputs produce a clear error with native Raw/Hex still available.

There is no sqlite-vec detection, schema scan, model/provider inference, dimension guessing from common models, similarity search, vector editing or SQL extension loading in this slice. A maximum 64 KiB BLOB contains 16,384 Float32 components. Larger or truncated native previews cannot be sent to plugins: native fetch remains capped at 1 MiB, and oversized BLOBs are never refetched in full for this inspector. Copy requires clipboard.write; Save Bytes remains the unchanged native byte export.

BinaryPreviewTests cover byte order, exact transport, malformed/oversized/empty/misaligned inputs, finite/non-finite statistics, maximum payload and revocation. BinaryPreviewUITests covers menu type matching, lazy BLOB fetch, the 2 MiB truncated fixture, source-byte preservation, light/dark layout and close/disable invalidation. The generic helper tests also cover cancellation with binary input and absence of database access even for a package declaring database.write.

Table actions — implemented read-only slice

petti.registerTableAction({
  id: 'definition',
  title: 'Show Table Definition',
  async run(context) {
    const result = await context.database.query(
      "SELECT sql FROM sqlite_schema WHERE type='table' AND name=?",
      [{type:'text', value:context.table.name}]
    );
    return {title:'Definition', text:result.rows.map(row => row[0].value || '').join('\n') +
      (result.truncated ? '\nResults limited.' : '')};
  }
});

Verification: PluginTableActionTests cover registration/duplicate validation, quoted/missing names, closed sessions, exact context, malformed output, missing permissions, writes denied while unlocked, source preservation, timeout/cancel/disable/recovery. PluginUITests covers native table-action panel behavior, the clicked-table context, read-only operation, disable/close/closed-session and light/dark layout. Ordinary command and inspector APIs remain intact.

Database actions — implemented read-only slice

petti.registerDatabaseAction({
  id: 'schema-names',
  title: 'Show Schema Names',
  async run(context) {
    const result = await context.database.schema();
    return {title:'Schema names', text:result.rows.map(row => row[0].value).join('\n') +
      (result.truncated ? '\nPartial listing; more objects may exist.' : '')};
  }
});

Verification: PluginDatabaseActionTests cover reference names/count-of-shown semantics, empty/200-row/text-budget limits, invalid/duplicate/excess registrations, permissions, missing/closed sessions, narrow context, write denial while unlocked, source preservation, malformed output and timeout/cancel/disable/recovery. PluginUITests covers the native action panel with read-only results, enable/disable, close/revocation and both appearances. Packaged tests exercise the bundled helper/resources.

Importers — implemented bounded file-to-table slice

petti.registerImporter({
  id: 'my-format',
  title: 'Import My Format',
  parse(input) {
    // input.text is the chosen UTF-8 file, never a path or database context.
    return {columns:['value'], rows:[[{type:'text',value:input.text}]], truncated:false};
  }
});

Verification: PluginImporterTests exercise actual JSONL parsing/mapping/commit, exact string values/defaults, source-row limits, malformed/precision/duplicate-key rejection, file-size/symlink limits, registration/permissions/parser isolation, invalid results, lock/license/confirmation, single-use drafts, schema change, disable/closed session, constraint rollback, cancellation, timeout and invalid mappings. PluginUITests exercises native preview/mapping in both appearances, locked denial, actual unlocked import, refresh, close and expired session. Existing plugin transaction tests remain regression coverage.

Custom JavaScript exporters — implemented bounded preview-to-file slice

petti.registerExporter({
  id: 'jsonl',
  title: 'Export Previewed Rows as JSONL',
  fileExtension: 'jsonl',
  render(input) {
    return input.rows.map(row => '{' + row.map((cell, i) => {
      let value;
      if (cell.type === 'null') value = 'null';
      else if (cell.type === 'text') value = JSON.stringify(cell.value);
      else if (cell.type === 'integer' || cell.type === 'real') value = cell.value;
      else throw Error('Unsupported storage type');
      return JSON.stringify(input.columns[i]) + ':' + value;
    }).join(',') + '}\n').join('');
  }
});

Verification: PluginExporterTests cover exact Int64/NULL/empty/escaped JSON bytes, locked-session saves, first-20/empty scope, unsupported values, input/output/registration/permission limits, missing approval, one-shot/disabled/closed/cancelled drafts, timeout, source/companion hard-link and symlink protection, replacement, staged cancellation, changed destination and temporary cleanup. PluginUITests covers native preview/save/subset labeling/light/dark, unchanged read-only source, close and disabled-save behavior. Packaged runs use actual bundled resources/helper.

Declarative inspectors — experimental flat-page slice

petti.registerInspector({
  id: 'find-schema',
  title: 'Find Schema Objects',
  description: 'Search schema metadata without scanning table rows.',
  actionTitle: 'Search',
  fields: [
    {id:'name', type:'text', label:'Name contains', initialValue:''},
    {id:'kind', type:'select', label:'Type', initialValue:'', options:[
      {value:'',label:'All'}, {value:'table',label:'Tables'}, {value:'view',label:'Views'}
    ]}
  ],
  async run(context, fields) {
    if (!context.database) throw Error('Open a database first.');
    return await context.database.query(
      "SELECT name,type,sql FROM sqlite_schema WHERE instr(name,?) > 0 AND (?='' OR type=?) ORDER BY name",
      [{type:'text',value:fields.name},{type:'text',value:fields.kind},{type:'text',value:fields.kind}]
    );
  }
});

Registration declares inert data; Petti renders the title, description, native form, explicit action button and bounded native table. There are no private SwiftUI objects, web views, raw HTML or arbitrary UI event callbacks. Bounded recursive declarative component trees are supported through layout/page, documented below. Fields support text, select, multiSelect, date, number and checkbox. No jobs, workspaces or app schema changes are implied.

The original fixed page/form/action/results arrangement remains supported. The composed native page API below adds stacks, forms, alerts/progress, charts and calendars. Writable workspace CRUD forms are documented below; arbitrary inspector callback graphs remain planned. Manager lifecycle streams remain host-only; the explicit public database change API is documented below.

Verification: PluginInspectorTests cover registration/field/result bounds, duplicates, parameterized quoted names, empty/truncated results, source preservation, read permission denial, no write capability even in a write-permitted package, exact Int64 output, closed sessions, timeout/cancellation/disable/recovery. PluginInspectorUITests cover the actual palette/form/table flow, locked unlicensed reading, light/dark layout, error/empty/disable/close states and exact native integer/NULL display. PETTI_PLUGIN_TEST_BUNDLE runs native tests against packaged resources/helper.

Reference and verification

Resources/Plugins/JSONTools.petti-plugin uses only registerCommand, selection and text output. JSON Outline uses public preview registration and the bounded structured tree output. Native Raw/Hex/JSON/Image representations remain separate; Embedding Inspector uses the same public preview registry and value-only input. Structured trees are implemented below; arbitrary custom native renderers remain unavailable.

PluginTests cover validation/paths/version/duplicates, consent/persistence/digest changes, user rejection, permissions/absent native APIs, actual JS execution/errors, timeout/cancellation/output/recovery and in-flight disable. PluginUITests cover native output/layout, copy permission, disable, missing selection and late-close results. PETTI_PLUGIN_TEST_BUNDLE=/absolute/Petti.app swift test --filter PluginUITests checks packaged helper/resources.

Planned, unavailable

Unrestricted network/filesystem access; SQLite native extension loading; custom JavaScript streaming transformations and filtered/selected-row plugin stream scopes; durable/resumable jobs and unattended automation; production vertical allocation/timetable applications. The bounded Analytics Workbook and its supported sources/transforms are implemented below. General foreign-key/CHECK definitions and raw SQL/data-transform App migrations are unavailable. Implemented extensions are documented in the sections below; ordinary databases are never implicitly adopted.

Inspector result tabs (implemented, experimental V1)

An inspector may return {tabs:[{id,title,table}]} instead of a single table. Each table is the same typed {columns,rows,truncated} result. Do not mix top-level tabs with columns/rows/truncated, even null values: the host rejects ambiguous shapes. Existing single-table plugins continue unchanged.

return {tabs:[
  {id:'tables',title:'Tables',table:tablesResult},
  {id:'views',title:'Views',table:viewsResult}
]};

Inspector metrics (implemented, experimental V1)

Both single-table and tabbed responses may add a top-level metrics array. Omit it or use [] for none; null is invalid. Metrics-only responses are not supported: retain a table (which may be empty) or tabs.

return {tabs,metrics:[{id:'tables',label:'Tables (total)',value:'52'}]};

Verification covers exact Int64 extrema through the helper and JSON roundtrip, invalid types/formats/limits, shared payload bounds, empty schema zeroes, totals beyond the tab limit, read permission denial, source preservation, lifecycle and native light/dark bounded rendering.

Inspector bar charts (implemented, experimental V1)

A single-table or tabbed response may add one top-level chart. Omit chart when absent; null is invalid. Existing table/tab/metric responses retain their behavior. Charts-only responses are not supported.

return {tabs,metrics,chart:{
  type:'bar',title:'Schema object totals',
  points:[{id:'tables',label:'Tables',value:'52'}, {id:'views',label:'Views',value:'0'}]
}};

Verification covers malformed/oversized charts, aggregate byte caps, exact maximum integer transport/roundtrip, real counts beyond truncated tab rows, empty/all-zero states, native light/dark rendering at 12 categories, Chart tab switching and the actual native Values selector/table.

Inspector form sections (implemented, experimental V1)

Registration can add sections:[{id,title,description?,fields:[fieldID]}]. Omit sections (or null) to retain the original flat form. Field definitions and submitted values stay unchanged.

sections:[
  {id:'criteria',title:'Object criteria',description:'Match object names and types.',fields:['name','kind']},
  {id:'options',title:'Result options',fields:['limit','internal']}
]

One to four nonempty sections are allowed. IDs must be unique and use command-ID syntax; titles are nonblank and <=160 UTF-8 bytes; optional descriptions <=512 bytes. Every declared field must be referenced exactly once across sections; unknown, repeated or missing references reject registration atomically. Sections share the existing 16-KiB page limit. They are ordered inert layout metadata, not nested executable views or additional fields.

Petti uses native form sections with accessible headings and help text inside the bounded scrolling form. Field order follows section/reference order. The same field renderer/bindings serve both flat and grouped pages; changes never auto-run queries. Existing number drafts, UTC dates, multi-select ordering, lock/permissions and cancellation rules are unchanged. Schema Inspector groups existing criteria and options through this public metadata; no new queries or capabilities are added.

Verification covers invalid section counts/IDs/labels/help/references, reordered fields, old definitions, real helper registration/submission, native text editing and explicit execution in both appearances, and existing result/lifecycle regressions.

Inspector-backed workspaces (implemented, experimental V1)

A workspace is an explicit optional collection of existing inspector pages and registered job actions. It does not own/adopt a database or create metadata. Registration uses the same public JavaScript API as third-party definitions (user execution requires the isolated helper and consent).

petti.registerWorkspace({
  id:'orders',title:'Order Inspector',description:'Inspect order metadata.',
  match:{tables:['orders']},
  pages:[{id:'overview',title:'Overview',inspector:'schema-overview'}]
});

Verification: real reference matching/execution without source mutations, required-table match/mismatch and stale-schema rejection, malformed registration, schema permission denial, disabled/closed sessions, explicit entry/page execution, cancellation on page change, native light/dark layout, native raw button/accessibility/Escape and preserved raw grid state.

Experimental app creation — implemented, new files only (upgrades below)

petti.registerApp(definition) registers inert metadata. Requires approved database.write; it does not execute DDL during registration, discovery, matching or database opening. The host's File → New Database… window lists enabled app definitions, shows the tables/version, requires an editing license and an explicit destination. Enabling a package alone never creates a file. Notes App is the bundled public-API reference; enable it explicitly.

petti.registerApp({
  id:'notes', title:'Notes', description:'A local notes database.',
  migrations:[{version:1,tables:[{name:'notes',columns:[
    {name:'id',type:'INTEGER',primaryKey:true},
    {name:'title',type:'TEXT',required:true},
    {name:'body',type:'TEXT'}
  ]}]}]
});

Implemented contract:

Notes App creates notes, note_labels and note_links across four versions and registers a schema inspector/workspace using existing public read APIs. The normal grid remains available for editing; the optional Records menu now provides reviewed typed CRUD. The Notes workspace also provides the bounded listing/cleanup job flow documented below. This is a platform reference, not a finished notes product.

Planned, not exposed: broader schema matching/conversion, arbitrary SQL/data-transform migrations, remote template dependencies and persistent/streaming jobs. Bounded new-file templates/sample data and read-only jobs are documented below. No ordinary database gains _petti_* tables merely by opening it. User execution requires the isolated helper and explicit consent.

Verification: public reference creation, permission and duplicate-registration rejection, ordered/idempotent/appended migrations, wrong owner/newer/tampered history, real rollback, ordinary/RO-file rejection, stale policy/revoked lifetime, cancellation, existing destination/sidecar/symlink protection, staging cleanup and native license/create/read-only-open/lifecycle/light-dark layout tests.

Experimental owned-app upgrades — implemented

The same immutable registerApp migration list now supports upgrading an already owned database. Append contiguous versions; do not edit previously applied definitions. Notes App v3 adds note_links without changing v1/v2. Package content changes require renewed approval. There is no new JavaScript SQLite/DDL capability or automatic upgrade callback.

Host UI: Tools → Command Palette → App Database… → choose the enabled owning app → Review → Create Snapshot and Upgrade… → native confirmation. Review is read-only and shows current/target versions and pending table creation and exact generated schema-operation SQL. Up-to-date is a no-op. Wrong owner, ordinary databases, missing/changed/future history, altered metadata tables and extra metadata triggers/indexes are rejected. Review never adopts a database. Metadata validation is deliberately strict; manually recreated/rewritten metadata may be incompatible.

Execution:

Bounds/limits: the implemented validated schema DSL below, not arbitrary SQL/data transforms; no generic schema adoption. Metadata history verifies compatibility, not authenticity against a user deliberately forging a database. This is not protection against adversarial same-user filesystem rename races. Upgrades are user-triggered, can hold writer access for snapshot duration, and never run on the UI thread. Ordinary open/matching/enabling remains mutation-free.

Verification covers v2→v3 on WAL and DELETE journaling, committed data after review, competing writer exclusion, snapshot reopening/restoration to a separate file, no-op without another snapshot, DDL rollback, snapshot failure, stale schema, malformed history/metadata, lock/license/approval denial, cancellation/relock/session revocation, plugin disable/source replacement during capture, and native review/result/error/lifecycle/light-dark rendering.

Experimental exact-schema adoption — implemented

Adoption is a separate explicit action in Command Palette → App Database… → Review Adoption → As schema vN. The user selects the enabled app and a declared schema version. It does not infer an app/version, automatically adopt matching workspaces, run pending migrations, or reinterpret arbitrary databases.

Review is genuinely read-only. The host builds the expected schema from the registered ordered schema migrations through the selected version and compares SQLite's stored sqlite_schema.sql with the exact canonical SQL generated by Petti's existing schema writer. Table names alone are insufficient. The table set must match exactly. Extra/missing/modified tables, views, undeclared indexes, triggers, SQLite analysis/statistics objects, existing/partial/foreign _petti_* metadata and duplicate migration table names are rejected. Implicit primary-key indexes with NULL SQL belonging to matched tables are allowed. Up to 90 declared table/index objects and 129 schema entries are inspected; the existing inspection deadline applies. No table rows are scanned or counted.

Intentional limitation: SQL with equivalent semantics but different quoting/formatting/declarations may fail matching. This first implementation requires exact canonical definitions. It does not claim general schema equivalence or provide a mapping/conversion wizard. No existing user schema is normalized or rewritten to make it match.

A successful review names the app/plugin, selected version and matched tables. Native confirmation states that _petti_app and _petti_migrations will be added, existing rows/tables remain intact, and other writers may pause for snapshot capture. Enabling the plugin or reviewing the file does not change ownership.

Execution uses the existing host-only app operation route (the reviewed plan carries adoption intent). All upgrade safeguards apply: explicit consent, live license/unlock epoch, enabled/current owner definition, source identity/regular-file checks, cancellation/revocation, schema-version recheck under BEGIN IMMEDIATE, writer-reserved SQLite backup, recovery lease, transactional metadata and commit guards. Compatibility is checked again under the writer reservation and immediately before metadata creation. Only ownership plus fingerprints/history for the selected migration prefix are inserted; no declared app table is created or modified and no later migration runs. A subsequent upgrade requires another review/confirmation and snapshot.

Failure rolls back both metadata tables and history. Completed snapshots remain available even after cancellation/failure; success can reveal the recovery snapshot in Finder. The pre-adoption snapshot contains the original ordinary database without Petti metadata. Closing the palette refreshes schema/grid and clears stale Undo after an attempt. Existing database viewing/editing and View Raw Database remain available.

Verification: metadata-free read-only review, exact schema/version and extra-object rejection, no table-content changes, WAL/DELETE snapshots, adoption→separate upgrade, existing ownership denial, lock/license/consent/stale review, snapshot failure, forced metadata-insert rollback, relock/cancellation/plugin disable/source replacement during capture, and native review/adoption/recovery/upgrade flow in both appearances. The native flow remains optional. Typed template configuration and local dependency resolution are implemented below; implemented templates/jobs and broader migration operations are documented below.

Experimental new-file templates — implemented

petti.registerTemplate({
  id:'notes-starter', title:'Notes Starter', description:'An optional example note.',
  app:'notes', workspace:'notes',
  sampleData:[{table:'notes',columns:['title','body'],rows:[[
    {type:'text',value:'Welcome'}, {type:'text',value:'Edit or delete this example.'}
  ]]}]
});

By default app references a registered app in the same approved/enabled package (the optional external-owner extension is documented below); optional workspace must reference that package's registered workspace and its required tables must exist in the app's declared schema. References are checked after all registration, so declaration order does not matter. Missing/incompatible references fail package loading atomically. No dependency downloads or automatic permission approval; external-owner resolution below does not run a dependency callback.

Limits: four templates/package; unique command-syntax IDs; nonblank title <=160 UTF-8 bytes, description <=1,024, definition <=64 KiB. Optional sampleData defaults to [] in the JavaScript bridge. At most eight insert groups, 100 total rows and 64 KiB encoded seed payload. Each group has an exact declared table name, 1–32 unique declared column names, nonempty rows of matching width. Values use the existing tagged SQLite encoding: integer as Int64 decimal text, finite real text, UTF-8 text, base64 blob, null without a payload. Invalid types, unsupported targets, wrong widths and excess budgets fail validation. Transport preserves Int64/BLOB values; ordinary SQLite column affinity and constraints still apply. Shared helper/package output limits also apply.

File → New Database… offers:

All three require an editing license and an explicit new destination; existing files/SQLite sidecars/symlinks are never replaced. Publication remains private staging → complete transaction → close/flush → no-replace rename. Failed/cancelled/revoked template work is not published. For templates, schema, ownership/history and optional parameter-bound inserts commit in one transaction, under the existing migration deadline and per-row policy/commit checks. A seed constraint failure rolls everything back, including schema/history. Seeds cannot target Petti metadata or existing files and are unavailable during upgrade/adoption. There is no raw SQL or JavaScript seed callback.

The new file opens through the normal read-only browser. A referenced workspace remains available through Tools → Command Palette → Workspaces…; it does not replace/trap the raw database UI. No extra template metadata is injected. Notes Starter demonstrates the same public API with an optional note and label. This is a data template, not a finished notes app.

Limits: remote dependency acquisition and arbitrary configuration callbacks, arbitrary seed transformations, auto-entering a workspace and richer template lifecycle are not implemented. No automatic snapshots are needed for new-only files; no source is modified. Existing new-file cancellation/publication and force-termination staging limitations still apply.

Verification: reference with/without samples; exact Int64/text/real/NULL/BLOB values; missing app/workspace and duplicate registration; seed target/shape/budget/type rejection; seed constraint rollback and revocation; cancellation cleanup; valid blank SQLite header/empty schema/no metadata; no overwrite and consent/license/disable gates; native blank/empty/template creation, read-only reopen and sample-choice reset in the normal new-file flow.

Read-only jobs — implemented, experimental

petti.registerJob({id, title, async run(context, fields) { ... }}) registers an explicitly run read-only job. Maximum eight per package; IDs/titles follow command rules, and a package must request database.read or schema.read. Supported keys are id, title, run, optional fields, mode (defaults to read) and snapshotBeforeRun (defaults to false for read jobs). Read jobs cannot request snapshots or return mutations. Write jobs use the separate reviewed protocol below; unsupported flags fail registration. Registration never runs the job. Tools → Command Palette → Jobs offers native Run/Cancel, actual counts, structured logs, result/error/status and Done. The panel must remain open. The host Automation inbox below can queue explicitly enabled rules, but running executions are not persisted or replayed. Snapshot-backed writes are recorded in Plugin Edit History; read-job output remains session-only. There is no scheduler.

petti.registerJob({
  id: 'inspect-sample', title: 'Inspect a bounded sample',
  async run(context) {
    const sample = await context.database.query('SELECT name FROM sqlite_schema LIMIT 20');
    const total = sample.rows.length;
    let characters = 0;
    for (let i = 0; i < total; i++) {
      characters += (sample.rows[i][0].value || '').length;
      context.progress(i + 1, total);
    }
    context.log('info', 'Sample inspected without writing.');
    return {title:'Schema sample', text:`${total} names sampled`,
      metadata:{nameCharacters:String(characters), scope:'bounded sample'}};
  }
});

The frozen job context exposes database.query(sql, typedParameters = []), database.schema(), database.tableSchema(name, section), optional database.changes/onChange with database.events, progress(completed,total) and log(level,message). Database methods retain the existing read/schema permissions, eight calls/run, per-query deadlines, 200-row/64-column/byte limits and genuine read-only connections. No selection, file, clipboard, transaction or arbitrary app internals are provided. Even a package with database.write running against an unlocked file receives only reads in this API. Separate queries do not share a transactional snapshot; live commits may differ between reads.

Progress counts must be integers: total 1…1,000,000,000, fixed throughout a run, and completed 0…total, nondecreasing. Omit progress for zero work; completion state is independent and never fabricates 100%. Logs use info, warning, or error plus a nonempty message of at most 1,024 UTF-8 bytes. The host allows at most 128 events including at most 64 logs; each serialized event is at most 4 KiB. Event delivery waits for host validation/acknowledgement, preventing an unbounded producer queue. Invalid events terminate the invocation. Logging an error does not itself fail the job: throw to fail it.

Return {title, text, metadata?}. Title is nonblank, at most 640 UTF-8 bytes; text at most 64 KiB. Metadata defaults to {} and supports at most 16 string pairs, keys 1…80 bytes, values up to 512 bytes, at most 4 KiB encoded total. Native UI displays it as text, not executable UI or paths. No automatic clipboard or file output.

Job budget is separate from commands: 30 seconds wall time, helper CPU soft/hard limits 15/16 seconds. Ordinary command limits remain unchanged. Cancel kills the helper and cancels active database work; no cooperative JS cancellation polling is required. Package disable/discovery, session close/replacement, or closing/reopening the job/palette invalidates work. The host checks session identity while computation runs and before accepting the result, with bounded polling. Late results do not repopulate a closed panel. Failure can leave genuine earlier progress/logs visible; Run Again starts a fresh run.

Database Tools' Sample table storage types uses only this API: bounded schema, up to six ordinary tables and 100 rows per table, tagged storage-type counts and genuine per-table progress. Output states its sample scope and live-read limitation. Existing approvals are digest-bound, so the changed bundled package requires consent again.

Read-only job limitations: no direct write/transaction bridge, background persistence or unrestricted third-party execution. Optional workspace job buttons are documented below. Reviewed write jobs are separately documented below. The fixed JavaScript arena and separate process watchdog apply to jobs as well. Do not market this as the complete app computation platform.

Reviewed write jobs — implemented, experimental

Register petti.registerJob({id, title, mode:'write', snapshotBeforeRun:true, async run(context) {...}}). The package must request database.write and at least one approved read/schema permission. The helper still receives only reads, progress and logs; it never receives a writable connection or transaction capability. Computation produces a bounded inert DML plan or owned-App SELECT materialization descriptor; it cannot directly save changes. Existing read job defaults are unchanged.

For ordinary DML, return {title,text,metadata?} plus statements, using the existing transaction statement shape. The separate materialization shape below replaces statements, never combines with it:

return {
  title: 'Review title change', text: 'One note title would change.',
  statements: [{
    sql: 'UPDATE notes SET title=? WHERE id=? AND title=?',
    parameters: [
      {type:'text',value:'Updated title'},
      {type:'integer',value:'42'},
      {type:'text',value:'Original title'}
    ],
    expectedChanges: 1
  }]
};

Limits: zero to 20 statements, at most 100 expected changed rows in total, at most 64 KiB encoded plan, SQL up to 32 KiB per statement and 128 typed parameters. Integer/text/real/blob/null values use the existing exact tagged encoding; NULL must omit its payload. An empty array is a legitimate no-change preview with no save action. Invalid shape/budgets/types fail before any write. Native review displays the actual SQL, parameter types/values and expected changes alongside the plugin's descriptive text. Prepared plans stay in memory and expire when discarded, replaced by a new preview, disabled/rediscovered or closed. They are not persisted or reusable after a save attempt.

Preview Changes → review → Snapshot and Save… → native confirmation → save is the only write-job UI flow. Preview can run while locked. Saving requires an editing license, actual unlocked write policy, an enabled approved package, a current session and explicit native confirmation. The native host saves only its immutable reviewed plan ID, not newly supplied helper SQL. Neither enabling a plugin, opening a database nor previewing a plan writes to the database or adds _petti_* metadata.

Petti retains a read-only connection's PRAGMA data_version from before computation until review is resolved. It compares that same connection before publishing the plan and again after acquiring BEGIN IMMEDIATE for saving. External SQLite commits, including changes made through Petti's other connections, invalidate the plan even if row counts are unchanged. Source identity/path checks reject replacement or symbolic-link source files. This deliberately favors a fresh preview over applying stale computations; it is not an adversarial filesystem tamper detector.

The writer reservation spans the complete SQLite backup and DML transaction. Other writers may pause; snapshot time/space scales with database size. SnapshotService's existing cancellation/space/deadline protection applies, with real copied-page progress. The snapshot is taken after computation/review, immediately before mutation with other writers excluded. snapshotBeforeRun:true therefore means before saving the prepared plan, not before the read-only computation. Disabling snapshots for write jobs is not supported in this first version. Snapshot failure prevents mutation; completed recovery snapshots remain in the normal history even if later validation or execution fails.

The existing authorizer/transaction engine enforces ordinary-table INSERT/UPDATE/DELETE only, no DDL/PRAGMA/ATTACH/RETURNING/functions/triggers/virtual or shadow tables/SQLite or Petti metadata/REPLACE. Per-statement affected rows must equal the reviewed expectation, and total changes including cascades remain capped. The mutation phase retains the existing approximately 1.5-second preparation/execution budget; the 30-second helper computation budget does not make SQL writes unbounded. Relock/license loss, cancellation, plugin disable and session revocation are checked before/during backup, during SQL work and at the SQLite commit hook. Source identity/path is rechecked before and after backup, during SQL work and at commit. A later statement failure rolls back earlier changes in the same plan.

COMMIT is the success boundary: the host returns actual changed-row count and a recovery snapshot lease with no further helper callback or cancellation rejection after an accepted commit. Cancel during saving requests rollback but cannot undo a commit already accepted. Closing the panel discards visible state; closing the palette refreshes the raw table and clears stale cell Undo after any attempted write. Jobs do not add individual changes to cell Undo. Recovery opens/reveals a separate snapshot; Petti never silently restores over the source.

Notes App's Trim note title whitespace (up to 20) is the public reference: reads the first 20 integer-ID/text-title notes, computes trimmed titles in JavaScript, reports genuine progress, then returns parameterized updates with old-title predicates. Users review and save explicitly. It works on a compatible ordinary notes table without adopting it or creating app metadata. It is a bounded maintenance action, not a finished notes application.

Owned-App large SELECT results are supported by materialization below; custom JavaScript streaming transforms remain planned. Also unavailable: directly editable inspector result tables, persistent job queues/resumable execution, scheduling/WASM, custom transaction management. Persistent mutation outcomes are available separately from a job queue. General app/schema migrations remain a separate API.

Workspace job actions — implemented, experimental

A workspace may include jobs, an ordered array of up to eight unique job IDs registered by the same package. Omit it (or use []) for the existing inspector-only workspace. Unknown/duplicate IDs, cross-package references and malformed arrays reject the entire package registration atomically. Registration order does not matter; definitions resolve after all declarations. Job references share the workspace's existing 8-KiB metadata budget and grant no additional permissions.

petti.registerWorkspace({
  id:'notes', title:'Notes Database', description:'Inspect and maintain notes.',
  match:{tables:['notes','note_labels']},
  pages:[{id:'notes',title:'Notes',inspector:'notes-list'}],
  jobs:['trim-note-titles','edit-note-title'] // A job registered in this package.
});

The workspace shows a small native Jobs menu only when it has actions. Choosing one opens the existing job panel in its ready state; it does not execute the job. Read-only jobs require Run, write jobs require Preview Changes followed by the established SQL/parameter review, confirmation, licensed unlock, recovery snapshot and atomic save. No helper receives private native views or a writable SQLite handle. Inspectors remain read-only.

The host rechecks current workspace registration, the same-package job binding, schema match, session identity and enabled permissions when Run/Preview is invoked. For write jobs the match check is included in the retained data-version review interval, so later commits/schema changes invalidate the plan. The host-only runJob/prepareWriteJob paths carry an optional workspace ID; it is not an arbitrary plugin privilege. Global palette jobs retain their existing independent behavior.

Closing the nested job cancels work and discards uncommitted plans. A write attempt clears stale workspace inspector results when the job panel closes; Load Notes/Run explicitly loads fresh data. It does not add automatic polling/querying. Closing/replacing the workspace or changing plugin configuration closes the nested job as well. Read/write job cancellation, budgets, COMMIT boundary and recovery semantics are unchanged. Reopening a normal palette resets the attempt state, but nested sheet cleanup preserves it until the raw browser has refreshed and cleared stale Undo. No individual job mutation is added to cell Undo.

View Raw Database / Escape remains available in the workspace, including after errors or plugin revocation. The job panel has its own Done/Cancel/Escape to return to the workspace. Returning raw preserves the selected table and current search/filter/sort controls; after an attempted write the existing grid reloads rather than displaying stale rows. The raw browser still opens first for every database/template.

Public bundled examples:

Verification: invalid/missing/duplicate/foreign references and omitted-field compatibility; same-package read invocation and stale schema/disabled/unbound rejection; native template creation → locked raw open → Notes listing → denied locked save → intentional unlock → reviewed save → recovery → refreshed listing → native raw Escape, preserving selection/search and clearing Undo. Closed reviews and revoked workspaces cannot later commit. Existing job cancellation/replacement and workspace lifecycle tests remain in the regression suite.

Remaining: direct editable workspace tables, arbitrary page composition and persistent page/job state. The bounded Notes reference workflow is documented below. Bounded job forms are implemented below. This bounded integration does not introduce scheduling, pipelines or a new execution engine.

Typed job input forms — implemented, experimental

Both read and reviewed-write jobs accept optional fields, using the same field definitions as inspectors: text, number, checkbox, select, multiSelect and calendar date. Omission/null/empty array preserves input-free jobs. The second callback argument is the submitted, validated field dictionary; existing one-argument callbacks still work.

petti.registerJob({
  id:'lookup', title:'Look up a note',
  fields:[{id:'note-id',type:'text',label:'Note ID (exact integer)',initialValue:'1'}],
  async run(context, fields) {
    const id = fields['note-id'];
    if (!/^-?(0|[1-9][0-9]*)$/.test(id)) throw Error('Enter an integer ID.');
    const result = await context.database.query(
      'SELECT title FROM notes WHERE id=? LIMIT 1', [{type:'integer',value:id}]);
    return {title:'Note', text:result.rows.length ? result.rows[0][0].value : 'Not found'};
  }
});

Up to eight unique declared fields, 16 KiB encoded definitions and 16 KiB submitted values. Existing field limits apply: text/select values up to 1,024 UTF-8 bytes; finite numeric values within JavaScript's exact integer range, optional min/max/integer constraints; actual booleans; strict bounded YYYY-MM-DD dates; up to 32 distinct declared multi-select options. Registration validates defaults; execution rejects missing/extra fields, wrong types, invalid options and exceeded bounds at the host. Numeric drafts are parsed on explicit Run/Preview; an invalid draft never reuses the previous value. Exact SQLite Int64 identifiers should use text plus tagged integer binding, not number fields.

Opening a job only initializes defaults. Run/Preview captures values once. The helper freezes the field object and nested selection arrays; values remain inert data, never code. The native form is disabled while running/saving. No new database permission or writable helper capability is introduced. Fields are not automatically persisted; reopening restores defaults. Job forms are flat and scroll within a bounded area; inspector sections/general recursive layouts are not job APIs.

Editing any field clears old output/errors/progress and synchronously revokes a pending write preview. The next save needs a fresh preview; confirmation is bound to that specific preview ID. Close/replacement also revokes the review. Host-only PluginJobPlan.invalidate() shares the transaction's revocation lease so invalidation is effective before asynchronous draft cleanup. Existing source-revision, lock/license, snapshot, rollback and accepted-COMMIT semantics remain unchanged.

Notes Database → Jobs → Edit a note title proves the public path: exact signed 64-bit ID lookup, nonblank new title, no-op/missing-note results, parameterized old-title-guarded update, explicit SQL/value review and recovery snapshot. The action also appears in global Jobs. It does not auto-select a row, rewrite bodies, insert metadata or save automatically. Native read-only inspection/preview remains possible; saving requires licensed unlock. Refresh the Notes listing explicitly or return to the raw spreadsheet after saving.

Verification: all six field types through the real helper; frozen arrays; malformed registration and typed/default/budget validation; IDs above JavaScript's safe integer range; recovery/no-op/missing notes; revoked/stale previews; native text input and layout in light/dark appearances, numeric drafts, frozen runs, pinned confirmations and close/cancel cleanup. Existing workspace/raw-refresh and inspector tests remain in the regression suite.

Notes reference app workflow — implemented, bounded

The bundled Notes package uses only the public App/template, inspector/workspace and reviewed job APIs. Its optional Jobs menu now includes Create a note and Edit note title and body, retaining title-only editing and whitespace cleanup. No private bridge or new permission is involved. Opening/enabling/matching never creates or modifies notes.

Create requires a nonblank title and explicit body storage: NULL or text (including empty text). SQLite allocates the INTEGER PRIMARY KEY at commit; the preview deliberately reports no future ID. Reload Notes after saving, or use View Raw Database. Creation conservatively accepts only the simple three-column Notes schema (id INTEGER PRIMARY KEY, title TEXT NOT NULL, body TEXT, in that order, ordinary unquoted/double-quoted names). Extra columns/constraints, STRICT/WITHOUT ROWID, different key types, comments and other SQL spellings may reject even otherwise compatible schemas. This gate avoids pretending arbitrary id columns allocate IDs. Use the raw editor for other schemas; this is not a general schema parser or adoption operation.

Edit uses an exact integer ID supplied as text. A body action explicitly chooses Keep existing body, Replace with text, or Set to NULL. Keep does not read or bind the body, so existing large/binary/multiline values are preserved unchanged. Replacement reads the current body within the existing query budget and requires ordinary text or NULL; binary/raw-text/oversized values and truncated query rows fail explicitly and remain editable through the raw browser. Empty text differs from NULL. Entered body text with Keep/NULL is rejected rather than silently ignored. Current form inputs remain single-line and limited to 1,024 UTF-8 bytes each; this is not a rich-text editor. No automatic field prefill or row selection is implied.

Changes produce parameterized plans with old-value predicates (body IS ? handles NULL), exact expected row counts and retained database-version checks. Missing/unchanged notes return no-op results. Saving uses the existing explicit review, licensed unlock, cancellation/revocation, mandatory recovery snapshot and atomic transaction. Completed notes are visible after explicit listing refresh; raw browsing remains the default and full editor. No automatic metadata insertion, deletion, scheduling or new persistence layer was added.

Proof: real template creation with sample → locked raw opening → workspace create preview → locked denial → unlock/snapshot/save → refreshed listing with SQLite-generated exact ID → body/title edit → recovery snapshot of prior NULL → refreshed listing → raw grid. Core tests additionally cover generated IDs above 2^53, NULL versus empty text, keeping a 100-KiB BLOB untouched, no-op/missing notes, stale-body conflict, malformed/oversized input, incompatible creation schema and disabled-plan rejection. Existing general rollback/cancellation/replacement tests remain authoritative for the unchanged engine. This fulfills a bounded reference-app proof, not the full 1.4 LTS release or specialized vertical-app requirements.

Plugin-local storage — implemented, experimental

Declare storage permission and obtain normal digest-bound enablement consent. Commands only receive context.storage; registration, import/export/preview callbacks and the inspector/action/job planning contexts do not gain this capability. No database is needed and this permission never unlocks or modifies an opened database.

const oldValue = await context.storage.get('preference'); // undefined if missing; JSON null stays null
await context.storage.set('preference', {showInternal:false});
await context.storage.remove('preference');

Values must serialize as finite JSON, at most 4 KiB and 16 nesting levels. Undefined/function/symbol/BigInt/nonfinite values are rejected, including nested values. Keys use [a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}. Each plugin has up to 64 keys / 64 KiB encoded total (JSON string escaping counts). A call returns the stored value, or undefined after removal. Read/modify/write across multiple calls is not an atomic transaction; concurrent independent invocations should not treat this as a counter/database API. The reference visit counter is an explicit diagnostic demonstration, not an accounting system.

The host chooses the plugin namespace under Petti's app-support PluginStorage directory next to its approval state. Plugins cannot supply root paths or another ID. Operations serialize, take a cross-process file lock, check bounded regular files, refuse direct directory/file symlinks and hard-linked files, and publish a flushed temporary file by rename. Lock waits are bounded to one second. Existing corrupt/oversized/unreadable files produce errors without overwrite. Missing values differ from stored null. Arbitrary ancestor-directory replacement by another same-user process is not a hardened adversarial boundary.

Calls share the existing eight-call IPC budget and command deadline. Disable/discovery/cancellation prevents queued/unpublished work; a final cancellation check precedes atomic rename. A published preference remains saved if later JavaScript/transport/cancellation fails. Command errors disclose prior publication so users can read the saved value before retrying. No automatic rollback, silent recovery, database metadata, secret logging or filesystem grant is added. This is preferences storage, not a credential vault. User execution requires the signed isolated helper and explicit consent.

Database Tools demonstrates save/read/reset with Remember a tools visit, Read saved tools visits, and Reset saved tools visits. Nothing runs automatically. Re-enabling the changed bundled package requires renewed approval.

Structured command logs — implemented

Commands receive context.log(level, message); jobs retain their existing context.log behavior. Levels are info, warning, error. Each message is nonempty and at most 1,024 UTF-8 bytes; at most 64 log messages per invocation (within the existing total event cap). Invalid levels/types/oversized or excess messages fail execution. Messages stream through an acknowledged bounded channel; callbacks cannot outrun the host.

The palette shows a collapsible Command Log. Actual earlier messages remain visible after a command fails; starting another operation, cancellation or closing clears them. Logs are kept in memory only, not a persistent log file, analytics or automatic DB-content capture. Plugins choose the text and should omit secrets. Late messages cannot repopulate a closed/replaced panel. General inspector/action/preview logging is not exposed yet. Database Tools' storage demonstration emits a real post-save log.

Detailed table metadata — implemented

Where a public context already exposes database, it now includes:

await context.database.tableSchema('orders');               // columns
await context.database.tableSchema('orders', 'foreignKeys');
await context.database.tableSchema('orders', 'indexes');

Requires schema.read, independently of database.read. Returns the existing tagged {columns,rows,truncated} table. Sections mirror SQLite's table_xinfo (including hidden/generated flags), foreign_key_list (one row per key column) and index_list (index summary, not expression/column expansion). Names are main-schema table/view names, at most 512 UTF-8 bytes and no NUL; unknown names/sections fail explicitly. A missing object does not masquerade as an empty schema.

The host generates quoted metadata-only PRAGMAs on genuine read-only connections. Plugins cannot pass PRAGMA text or options; arbitrary PRAGMA/table-valued PRAGMA access through query() remains denied. Existing row/column/cell/byte/deadline limits, closed/replaced session checks and revocation apply. Each call consumes one bridge call. Separate sections can observe different committed schema revisions, not a cross-call snapshot. No table-row scans, COUNT or writes occur.

Database Tools' Inspect columns, keys and indexes table action uses this public API, showing up to the first 20 metadata rows per section and bounded field excerpts. Large metadata reports truncation/errors instead of materializing the full schema. This metadata method does not itself subscribe to changes or edit schema. The separate changes/onChange and owned-App migration APIs are documented below.

Composed native pages — implemented, experimental (2026-09-30)

Inspectors and workspace pages may add a layout value to registration and return {page: node} from run. Legacy table/tabs/metrics/chart outputs still work. layout and legacy sections cannot be combined. No JavaScript rendering, native view reference or raw database handle crosses this boundary. The host provides the normal Run/Cancel/Done controls even if the layout has no buttons. Nothing executes merely by opening the page.

A node has unique id, type, optional title and the following type-specific properties:

TypeProperties and behavior
page, stack, formchildren; stack optionally axis: 'horizontal' or 'vertical'; native layout/form
texttext, selectable plain text, no HTML
fieldfield: ID of a registered inspector field; uses existing text, number, date, select, multi-select or checkbox validation
buttontitle, action: 'run' or 'reset'; host validates/run invokes the existing inspector callback; reset cancels and restores defaults
resultsSlot for the actual action result, including legacy tables/tabs/metrics/chart or a composed page
tabs1–6 children with titles
tabletable: existing tagged {columns,rows,truncated} result
metricmetric: {id,label,value} with canonical exact Int64 string
alerttitle, text: user-clicked native information alert with OK dismissal; never permission consent or write authorization
progressOptional title; host binds real context.progress events, indeterminate while running without events, actual completion/error/idle status
treetree: bounded expandable tree values described below
chartchart: chart value described below
calendarcalendar: schedule value described below

field, button, and results belong only in registered layouts; returned result pages cannot introduce executable controls or new inputs. Every declared field must be referenced exactly once. Existing eight-field, field-type and validation limits remain. Node IDs are unique per page, at most 80 UTF-8 bytes. A page allows 64 nodes, depth eight, 200 total table rows, and 256 KiB encoded; text at most 8 KiB per node and titles 160 bytes. Container-only children and type-specific payloads are validated before native publication. Transport is additionally preflighted for JSON nesting before recursive decoding. Existing registry/result/runtime limits also apply, including 16-KiB registration metadata, eight bridge calls and inspector runtime deadline.

petti.registerInspector({
  id:'search', title:'Search', description:'Read-only example', actionTitle:'Search',
  fields:[{id:'name',type:'text',label:'Name contains',initialValue:''}],
  layout:{id:'page',type:'page',children:[
    {id:'form',type:'form',children:[{id:'name-input',type:'field',field:'name'}]},
    {id:'progress',type:'progress',title:'Loading'},
    {id:'results',type:'results'}
  ]},
  async run(context, fields) {
    context.progress(0,1);
    const table=await context.database.query(
      'SELECT name FROM sqlite_schema WHERE instr(name,?)>0 LIMIT 100',
      [{type:'text',value:fields.name}]);
    context.progress(1,1);
    return {page:{id:'result',type:'table',table}};
  }
});

Inspector context.progress(completed,total) uses safe integers and the existing acknowledged bounded event protocol: fixed positive total, monotonic completed in range, at most 128 total events. It runs off MainActor. Invalid/regressing progress fails execution, and cancellation/disable/close prevent stale publication. Progress does not grant writes or extend time limits. Inspectors remain read-only; writable workspace CRUD uses the separate reviewed record forms below. Buttons currently invoke/reset the registered inspector, not arbitrary callback graphs or wizard state.

Trees

A cell previewer may return {title,text,tree:[{id,label,value,children?}]}. title and text remain required for compatibility and safe text copying. The host displays the tree with native disclosure/keyboard expansion. Tree components in pages use the same value format. Limits: 500 nodes, depth 32, unique IDs ≤160 bytes, labels ≤512 bytes, values ≤2,048 bytes, encoded tree ≤128 KiB, plus the enclosing output/page budget. This is bounded display data, not a lazy recursive query API. Bundled JSON Tools supplies a 256-node/16-level outline and explicitly labels partial containers; original raw cell bytes stay intact.

Charts

{type,title,points:[{id,x,y,series?}]} supports bar, line, area, pie, donut, scatter. Up to 100 points / eight series, unique IDs ≤80 bytes, x labels ≤80 bytes; y is a finite decimal string with magnitude ≤1e15. Pie/donut values are nonnegative. Scatter x must also be a finite numeric string within ±1e15; other x values are ordered categories supplied by the plugin. Native Swift Charts renders geometry through Double; the Exact values checkbox reveals the original decimal strings in an accessible native table. Labels and VoiceOver values preserve supplied strings. Empty/zero pie data is explicit. No database query executes from a chart descriptor: plugins use the permission-checked read API and supply bounded results. Multiple charts can appear in page stacks/tabs; general unbounded dashboard queries and editable charts are not implied.

Calendar/schedule

{title,events:[{id,title,start,end,detail?}]} supports up to 100 uniquely identified events. IDs ≤80 bytes, titles ≤160, details ≤1,024. Start/end must be valid ISO timestamps with seconds and an explicit Z or numeric timezone; end cannot precede start. The native graphical day picker shows an agenda for the selected UTC day, including overlapping multi-day events, sorted by actual start time. Empty days are explicit; an initial nonempty schedule opens at its earliest event. This is a read-only schedule: drag editing, recurrence rules, conflict resolution and timetable generation are separate features. It does not alter the source schema or supply fabricated events.

Bundled Database Tools uses only these public APIs for Schema Dashboard (filter form, notice, progress, metrics, table and pie) and Appointment Schedule (calendar, source rows and line chart). The latter requires an existing appointments(id,title,starts_at,ends_at) table, reads at most 101 rows and displays at most 100 with truncation notice. Missing/incompatible data produces an error. It never creates a sample table in a user's database. Existing simple table browsing and View Raw Database remain unchanged.

Reviewed table/database write actions — implemented, experimental

Both action registries accept mode: 'read' | 'write' (default read). Read actions preserve their original output and behavior. Write actions require database.write in addition to existing registry permissions. A table action still requires selection.read; database actions require read/schema permission. The callback receives the existing read-only action context. It cannot call transaction/write methods.

petti.registerTableAction({
  id: 'trim-title', title: 'Trim title', mode: 'write',
  async run(context) {
    if (context.table.name !== 'notes') throw new Error('Choose notes.');
    const rows = await context.database.query('SELECT id,title FROM notes LIMIT 1');
    if (rows.truncated || !rows.rows.length) throw new Error('No complete record.');
    const [id, title] = rows.rows[0];
    if (title.type !== 'text') throw new Error('Expected text.');
    return {title:'Review title', text:'Trim the selected table’s first note.',
      statements:[{sql:'UPDATE notes SET title=? WHERE id=? AND title IS ?',
        parameters:[{type:'text',value:title.value.trim()},id,title],expectedChanges:1}]};
  }
});

The returned object uses the write-job result/mutation format (title, text, optional metadata, statements). All existing bounded mutation budgets apply. Explicit Preview Changes computes a plan; it never modifies data. Save requires exact SQL/parameter review, confirmation, licensed unlock, a recovery snapshot and one atomic transaction. Table actions are restricted by the SQLite authorizer to the chosen ordinary table. Database actions use the existing ordinary-table DML whitelist. Neither gains DDL, arbitrary PRAGMAs, handles, filesystem, native views or an independent writer.

Plans retain source identity, generation and the original connection's data_version. Saving captures the current lock/license epoch and enforces it throughout mutation. External writes/stale contexts require a new preview. Cancel/close/disable invalidates the plan; failed expectations or SQL roll back. Notes App's table Review note title cleanup and database Review Notes title cleanup implementations use only these public APIs and examine at most 20 notes.

Writable workspace records — implemented, experimental

A workspace may declare up to four optional forms (within its existing 8-KiB registration budget). This requires database.read, schema.read and database.write. Each target table must appear in match.tables.

forms: [{id:'note-records',title:'Edit Notes',table:'notes',key:'id',fields:[
  {id:'title',type:'text',label:'Title',initialValue:''},
  {id:'body',type:'text',label:'Body',initialValue:''}
]}]

The optional native Records menu loads the first 50 records by indexed INTEGER PRIMARY KEY. Larger sets are explicitly labeled; View Raw Database remains available for all rows. Registration/matching/opening the database never writes or automatically loads these records. Forms support New, select, edit, delete, reload and explicit NULL toggles. Every mutation goes through the reviewed snapshot-backed action/job boundary. No JavaScript save callback or raw SQL is needed: Petti generates quoted parameterized statements and guards updates/deletes with the exact key and original mapped values.

Each form has 1–8 existing typed fields; IDs map to actual column names and must satisfy both field and SQL-identifier validators: a lowercase first letter, then lowercase letters/digits, maximum 64 characters (underscores and hyphens are not accepted by this intersection). The key must be a single ordinary INTEGER PRIMARY KEY rowid alias and cannot be a field. WITHOUT ROWID/virtual/shadow tables and incompatible schemas are rejected. New records let SQLite allocate the key. Existing signed 64-bit keys are retained as tagged integer strings, never converted to JavaScript numbers.

Mapping: text/date/select → declared TEXT; multiSelect → TEXT containing a JSON string array; checkbox → INTEGER 0/1; number → INTEGER when integer:true, otherwise REAL. Existing range/date/options rules apply. Numeric input is finite and bounded to ±(2^53−1); larger data needs Raw Database. BLOBs, arbitrary affinity conversions, composite/text keys and direct editing of inspector result tables are not supported. NULL is distinct from empty text and permitted only for nullable mapped columns. Unmapped columns retain their values/defaults; ordinary SQLite constraints still apply.

A stale loaded form cannot overwrite an external change: source revision is checked before planning and again before saving. Reload resets selection/inputs. Closing, plugin revocation and subsequent previews discard outstanding reviews. Notes Database → Records → Edit Notes provides real create/update/delete against an ordinary matching database without adding Petti metadata.

Broader app schema migrations — implemented, experimental

Each ordered migration retains tables: [] and may add operations: []. There must be 1–8 combined table creations/operations per version. Table creation happens first, then operations in array order. Supported operation objects:

const operationExamples = [
  {kind:'addColumn',table:'notes',column:{name:'color',type:'TEXT',defaultValue:{type:'text',value:'blue'}}},
  {kind:'renameColumn',table:'notes',from:'title',name:'heading'},
  {kind:'dropColumn',table:'notes',name:'color'},
  {kind:'renameTable',table:'notes',name:'entries'},
  {kind:'dropTable',table:'entries'},
  {kind:'createIndex',table:'notes',name:'notes_title_index',columns:['title'],unique:false},
  {kind:'dropIndex',name:'notes_title_index'}
];

Examples are alternatives, not one valid sequence. Identifiers are host-validated/quoted; operations can target only declared app objects. Columns optionally accept bounded typed literal defaultValue (≤4 KiB), never SQL expressions. Types must match; required added columns need a non-NULL default. Adding a primary key, dropping a primary-key/last/indexed column, invalid references/name collisions and unsupported operation shapes are rejected. Drop the declared index before its column. An index contains 1–8 columns. At most 90 active declared tables/indexes are supported, retaining existing per-table/version limits.

Before upgrading an owned app with these operations, Petti verifies the current physical schema exactly matches its declared migration history. Unexpected external tables/indexes/triggers or rewritten definitions fail closed. Expected schema is constructed in a private in-memory SQLite database off the UI thread; no user rows are copied/scanned for matching. Reviewed upgrades reserve the writer, create a recovery snapshot and run all pending DDL/history in one transaction. Failures/cancellation/relock roll back. Operations that require expensive scans may hit the existing migration deadline and safely fail; this is not an unbounded maintenance API.

Destructive operation SQL is visible in the existing confirmation review. Ordinary database opening never adopts, migrates or creates _petti_* metadata. Explicit exact-schema adoption understands the schema at the selected version, including declared indexes/renames. Templates validate seeds against the final schema. Historical migrations without operations/defaultValue retain their prior serialized fingerprints, so existing version history is compatible.

Notes App schema version 4 adds note_labels.color with a literal default and notes_title_index, exercising upgrade/snapshot/restore through the public DSL. Petti's product version is unchanged. Raw SQL migrations, data transforms, arbitrary foreign-key/constraint rebuilding and cross-plugin schema ownership remain unavailable.

Expanded permissions, explicit grants and local installation — implemented, experimental

Commands → Install Local Plugin… reviews a selected .petti-plugin directory's manifest and single entry script before writing anything. The native permission dialog explains requested capabilities, including database modification. Cancel installs nothing. Approved bytes are captured at review, so later source edits cannot replace what was approved. Installation publishes under Application Support/Petti/Plugins/<id>.petti-plugin; it never replaces an installed package or accepts a reserved bundled identifier. The current format installs manifest + entry script only; auxiliary assets/modules are not executable API inputs.

User packages are installed disabled. Installation is not approval to execute. Enabling requires a signed isolated helper and digest-bound permission consent, even for packages requesting no permissions. User packages mimicking a reserved bundled ID cannot execute. No marketplace, downloads, network access, auto-enable or database mutation is introduced. Existing bundled packages still require digest-bound enablement approval, and changed bundled code invalidates prior consent.

Two additional permissions:

file.readSelected now also permits the native Attach File… command input. The host reads a regular non-symlink file off the UI thread, at most 64 KiB. A grant belongs to one palette/plugin, is consumed on the next run and is cleared by cancellation/close. A host-only permission revision captured at sharing time invalidates it after any plugin configuration change, including changes from another window and disable/re-enable. Stale inputs are rejected before helper execution; reopen Commands and share again. Selecting a different plugin never shares the grant with it. No file picker opens merely because a plugin asks for permission.

// Available only when the user supplied that specific input.
const file = context.files?.readSelected();
// {name, byteCount, base64, text}; text is null for invalid UTF-8.
const clipboard = context.clipboard?.readText();
// A string, including an explicitly shared empty string.

These synchronous getters return frozen snapshots, not paths, handles or a general filesystem API. File names omit parent directories. Binary bytes retain base64 exactly; UTF-8 text is validated against those bytes. Missing grants omit the corresponding context member. Permission and size checks happen in the host before helper execution.

clipboard.write retains explicit Copy Result with a fresh host permission check. file.writeSelected now permits Save Result… after a command produces output. The host saves the exact reviewed result using an opaque publication ID, rechecks permission/generation/session/lease, stages atomically and publishes only to a new destination. It refuses overwrite, symlink destinations, open database/sidecar paths or aliases and managed snapshots. Even commands without database.read retain host-side source protection. Another command/window cannot substitute its output for an older preview; disabling/closing invalidates the appropriate result. Text saves retain the existing 64-KiB output limit and contain the displayed result, including any explicit preview-truncation notice.

Shared Input Tools exercises attached file, clipboard, typed selection, Copy and Save through these same APIs. Input inspection explicitly labels its 12,000-character preview limit; it is not a streaming conversion tool. Existing selected-file importer/exporter APIs are unchanged.

Typed selection context — implemented, experimental

Commands with selection.read additionally receive:

context.selection.table // captured table name or null
context.selection.value // exact tagged value of one complete loaded cell, or null
context.selection.grid  // {columns, rows, truncated}, or null

Integers remain decimal strings, BLOBs base64, and SQL NULL tagged null; they are not coerced to JavaScript numbers/empty strings. Existing .text and .cell APIs remain backward compatible. The editable text .cell identity is still a separate exact identity structure.

Grid capture uses the current Data browser's loaded cells only, in visible column/row order: at most 32 rows, 16 columns, 64 cells and 32 KiB encoded data. Deferred/oversized/nonrepresentable or unloaded rows are skipped and the result is marked truncated; capturing never fetches another page. Empty partial results are explicit. The selection is a captured value snapshot for the invocation and does not follow later UI changes. It is not an unlimited export, a row identity resolver or a live selected-row cursor. Query-result grid selection is not included. Existing schema/tableSchema metadata and command storage/logging APIs remain available under their original permissions/bounds.

Database change API — implemented, experimental

Available in commands, jobs and reviewed write-action planning contexts that hold database.events, with their existing call/deadline/cancellation limits. Ordinary read-only inspector/table/database action contexts retain their original read/schema-only permissions; preview/import/export callbacks have no event access. A fresh retained read-only observer connection is created only on first use, never on ordinary database opening or scrolling. It samples PRAGMA data_version and schema_version, not table rows/counts. No trigger, metadata table or source mutation is installed.

const first = await context.database.changes();
let cursor = first.rows.at(-1)[0].value;
const next = await context.database.onChange(event => {
  // Frozen {cursor, kind, timestamp}; callback runs inside this invocation.
  context.log('info', event.kind); // commands/jobs with existing logging
}, cursor);
cursor = next.rows.at(-1)[0].value;

changes(cursor = null) returns immediately after one sample. onChange(callback, cursor = null) is a one-shot bounded subscription, waiting at most one second for a change (and no longer than the remaining operation deadline). It invokes the callback for each non-idle event in the returned batch, then finishes. Call again explicitly to continue observing while the operation remains active. No callback/job survives closing its panel, cancellation, disable, source replacement or the operation deadline. At most eight bridge calls per invocation remain allowed; there is no hidden persistent watcher or automatic write/refresh.

The result uses the existing tagged table transport with columns cursor, kind, timestamp. Kinds: opened, databaseChanged, schemaChanged, reset, idle. Timestamps indicate observation time, not an exact commit timestamp. The final row always supplies the latest cursor, even on an idle timeout. Cursors are opaque/session-scoped. There are at most 32 retained hints; cursor gaps set truncated and emit reset. A cursor from a reopened session resets safely. Replay can coalesce multiple commits into one invalidation; it does not attribute a process/table/row or count mutations. Rolled-back transactions do not produce committed-change hints. Snapshot creation itself is not a source-database commit event.

Database Tools → Observe database changes (up to 6 seconds) demonstrates real repeated subscriptions, logs and completed watch-interval progress through the public read-only job API. It performs no schema/data analysis or automatic updates. Persistent change history, unattended schedules and reactive background workspaces are separate work; bounded explicit subscriptions are the implemented contract.

Configured templates and installed dependencies — implemented, experimental

Templates may add typed fields, configurationData, requires and appPlugin:

petti.registerTemplate({
  id:'personal-notes', title:'Personal Notes', description:'Your first note.',
  app:'notes', appPlugin:'com.petti.notes-app', workspace:'notes',
  requires:[{id:'com.petti.notes-app', minimumVersion:'1.0.0'}],
  fields:[{id:'title',type:'text',label:'First title',initialValue:'Welcome'}],
  configurationData:[{table:'notes',columns:['title'],fields:['title']}],
  sampleData:[]
});

The provider requires database.write. Native File → New renders the existing typed fields (≤8), validates the submitted values, and generates bound seed inserts into the declared app's final schema. Each configurationData entry inserts one row (≤8 entries); column/field lists correspond positionally and every field must be used. Types must match declared columns: text/date/select/multiSelect→TEXT (multiSelect JSON array), checkbox→INTEGER 0/1, integer number→INTEGER, other number→REAL. Existing finite/exact-number/date/options limits apply. Configuration rows and optional sample data share existing ≤8 seed groups, ≤100 rows and 64-KiB limits. No executable configuration callback or raw SQL crosses the boundary.

Up to eight requires entries identify installed, enabled, trusted, valid packages and minimum numeric major.minor.patch versions. Comparison handles large numeric components without integer overflow. No dependency is fetched or enabled automatically. Missing/disabled/incompatible dependencies produce a visible unavailable-template reason and prevent Create. The user enables the required installed package through the existing permission screen. An external appPlugin must be declared in requires; omitted means the provider's own app. workspace, if present, belongs to that app owner and must match its declared final tables.

The database records the actual schema owner's plugin/app metadata, not the template provider's. Dependencies are rechecked at creation and any plugin configuration change revokes in-flight new-file work. All migrations, configuration rows and selected samples commit in the existing atomic staging/new-only publication flow, with license/cancellation/rollback/no-overwrite checks. Bad fields/constraints leave no partly created destination. Ordinary opened databases are unaffected. Inputs reset on close/reload; creation opens in the normal read-only browser with View Raw Database available.

The bundled Personal Notes Template depends on Notes App, collects title/body and creates a real note without optional sample data. It demonstrates cross-package resolution using the same public API. Remote package resolution/installation, arbitrary schema conversion remain outside this contract. Local dependencies require their own enablement/permissions and isolated-helper eligibility.

Native streaming importer/exporter registrations (implemented, experimental V1)

petti.registerImporter({id:'csv-all',title:'Import CSV (whole file)',nativeFormat:'csv'});
petti.registerExporter({id:'typed-all',title:'Export Typed JSONL (whole table)',
  fileExtension:'jsonl',nativeFormat:'typed-jsonl'});

nativeFormat is either csv or typed-jsonl, on either registry. Omit parse/render; combining a native format and a callback is rejected. IDs, registration counts and permissions are unchanged: import requires file.readSelected + database.write; export requires database.read + file.writeSelected. Bundled JSONL Import/Export register both through this same API. Packages need fresh digest consent after a bundled update.

Petti owns the codec and the entire native transfer. No whole dataset, file path, spool path, raw handle or transfer callback enters JavaScript. This is not an unrestricted streaming JS engine. Existing custom callbacks and their smaller budgets remain unchanged.

Import flow: explicitly choose a regular file → private immutable disk capture → parse/stage all rows in private SQLite → review at most 20 rows/64 KiB plus exact staged total → map source to distinct editable destination columns → confirm → reserve writer, create recovery snapshot and commit every staged row in one transaction. Preview may run while locked; saving requires the existing lowest-layer license/lock/session/plugin checks. Changed destination schema, trigger/virtual/internal-table writes, REPLACE conflict policies, errors or revocation fail closed. Skipped columns retain SQLite defaults. The host records the committed count and recovery snapshot in Plugin Edit History. No implicit schema creation or App adoption occurs.

Export flow: explicitly preview up to 20 rows → native Save panel → export the entire ordinary table in one fresh read transaction. Data may differ from preview at save time; changed schema requires another preview. Grid filter/sort/selection do not apply. UI and button state clearly distinguish whole-table output from custom preview-only output. Export works while locked. Existing source/companion/alias protection, replacement consent, staged fsync and atomic publication apply; cancellation/error preserves the previous destination until publication.

Bounds: 1 GiB input/output, 10 million rows, 64 columns, 64 KiB per decoded cell and 1 MiB per encoded row. Preparation and each transfer have five-minute deadlines; these maxima are ceilings, not completion-time guarantees. Spool size is capped at 1 GiB; input capture temporarily needs additional disk space. Only bounded row previews are retained in memory. At most 16 live staging leases are admitted; closed drafts delete staging, and subsequent preparations remove abandoned unlocked staging from interrupted processes. Active leases are not removed. Cancellation, progress and source checks run off the UI thread; no automatic COUNT is used.

CSV: UTF-8, header required, comma delimiter, quoted multiline fields, all imported fields are TEXT (including empty strings). SQLite destination affinity still applies. Export quotes fields; NULL becomes empty text and BLOB is base64. CSV therefore does not preserve storage types and is not a lossless SQLite round-trip format.

Typed JSONL: first line is {"format":"petti-sqlite-jsonl-1","columns":["id","value"]}; every following line is an array of exact tagged SQL values in that order, e.g. [{"type":"integer","value":"9223372036854775807"},{"type":"null"}]. Supported tags are integer, finite real, UTF-8 text, base64 blob and null (no payload). This preserves the represented SQLite values; destination affinity/constraints still apply. It is distinct from the custom object-per-line JSONL importer. Invalid UTF-8 TEXT and deferred/oversized values fail explicitly; use ordinary SQL export for raw-byte preservation. Empty typed files still require the header; empty tables export a header.

Verification: PluginStreamingTests/UITests test real bundled registration, whole-file/whole-table native flow, immutable source review, exact integer/NULL/BLOB/multiline values, bounded previews, typed round-trip, symlink/sidecar protection, changed schema, atomic replacement, relock/rollback/revocation, temporary cleanup and native UI. PETTI_STREAM_STRESS_ROWS=1000000 swift test --filter PluginStreamingTests.testHundredThousandRowsExactRoundTripAndImmutableReview generates a larger fixture and checks process memory.

Persistent plugin mutation history (implemented, native host UI)

… → Plugin Edit History is a host feature, not a new JavaScript permission or a cross-plugin data API. The journal is under Application Support/Petti/PluginHistory, separate from every user database. It retains the latest 1,000 operations across files, with 50-entry keyset pages. Opening a database or running a read-only command does not create source metadata or a mutation record.

Records identify plugin, host operation label, source path/file identity, start/finish time, commit outcome, known changed-row count and an existing recovery snapshot where available. SQL text/parameters and cell values are not copied into the journal. Plugin-authored operation labels should not contain secrets. Current coverage: command transactions, custom/streaming imports, reviewed jobs/actions/forms, new App/template publication and owned upgrade/adoption. This is not a complete audit of normal spreadsheet edits, SQL console writes or other applications.

The initial durable outcome is unknown. Only accepted SQLite COMMIT / atomic App file publication becomes committed, independently of helper completion. Failed/rolled-back calls are not labeled committed. A process interruption or history-result publication failure may leave unknown; the UI explicitly instructs inspection before retrying. The history database and user database cannot share an atomic commit. If initial history recording fails, the mutation does not start; a result-publication failure after accepted commit is reported as committed/uncertain history, never as rollback. History retention can also evict an unusually long-lived record; that publication failure is surfaced.

Recovery opens the recorded snapshot genuinely read-only and never overwrites the source. Missing/deleted snapshots surface an error. Missing/replaced source identities are flagged; a reused path is not treated as proof that it is the original file. Command/custom-import operations without snapshots explicitly show no Undo/recovery. Reviewed jobs/forms/actions/upgrades and native streaming imports retain their snapshot-before-write policy. History eviction does not delete manually managed recovery snapshots; use Snapshots to remove unwanted copies.

Owned-App bulk results (implemented; API v1 experimental addition)

A registered write job may return materialization instead of statements:

return {
  title: 'Review normalized results',
  text: 'Replace the complete outputs table after review.',
  materialization: {
    app: 'my-app',
    table: 'outputs',
    columns: ['id', 'value'],
    sql: 'SELECT id, upper(value) AS value FROM inputs WHERE id >= ?',
    parameters: [{type: 'integer', value: '1'}]
  }
};

Requires database.read, schema.read, database.write, an App registered by the same plugin, and a database already created or explicitly adopted into that App at its current version. Host checks canonical owned schema/history. Merely matching a workspace never grants ownership. This API cannot adopt a database, change schema, write App metadata, append arbitrary SQL or target another App's tables. Read jobs, commands and table/database actions do not accept this result shape.

The native host executes one authorizer-restricted read-only SELECT against the current database, captures its complete output in private disk staging, and shows the actual destination row count, actual staged count, mapped columns, SQL/bindings and at most 20 preview rows. SELECT column aliases must exactly match columns in order; supplied columns must exist in the ordinary destination table. Other destination columns receive their normal defaults/NULL/rowid allocation. Type affinity and constraints apply at insertion, as in SQLite. A failed constraint rolls back the entire replacement.

Saving never executes the SELECT again. After explicit native confirmation, the host checks the retained data revision, reserves the writer with BEGIN IMMEDIATE, validates ownership again, makes a recovery snapshot, deletes existing destination rows and inserts the staged rows in one transaction. An empty result intentionally clears the table and is explicitly reviewed. All source tables remain unchanged unless the user explicitly selected that same table as the output. Changed-row history counts deleted + inserted rows. Cancellation/relock/license revocation/disable/close before SQLite accepts COMMIT rejects or rolls back the operation; cancellation after accepted COMMIT cannot undo it. A post-commit history-publication error can leave an unknown journal outcome, as documented for existing writes.

Limits: one destination; SQL <=32 KiB; descriptor <=64 KiB; <=128 exact tagged parameters; <=64 columns; <=64 KiB/cell; <=1 MiB encoded row; <=1 GiB encoded output and private SQLite max page count 262144; <=10 million input destination rows and output rows; five-minute staging and insertion deadlines (snapshot uses existing backup bounds). Disk staging previews share the 16-active-preview ceiling with native transfers. SQLite caches are bounded to 2 MiB per handle and temp queries use file storage. Sorting/grouping can still require temporary disk space and explicit scans; this is an opt-in job, never normal-open work. No arbitrary JavaScript bulk loop, raw handle or staging path is exposed.

Allowed SELECT functions match the ordinary read allowlist plus row_number, rank, dense_rank, lag, lead. ATTACH, PRAGMA, extension loading and unknown functions are denied. CTEs are permitted; canonical owned-schema validation rejects added views/triggers before staging and again under the writer reservation. The existing 20-statement/100-row ordinary mutation limit is unchanged. User-installed packages use the same bounds and require isolated-helper eligibility plus explicit consent.

Analytics Workbook reference (implemented, bounded)

Enable com.petti.analytics, then File → New Database → Analytics Workbook. Optional sample data is off by default. A new workbook remains ordinary SQLite and opens in the normal read-only browser. Open the optional Analytics workspace explicitly. Its Start Here page describes the complete workflow; Records edits persisted steps/labels/chart settings. No native-only plugin escape hatch is used.

Scope intentionally excluded: XLSX/Parquet/Arrow, streaming arbitrary JSON arrays, inferred column schemas, visual transform editors, scheduled pipelines, result provenance/run archives and external connectors. These do not masquerade as inactive UI controls. The normal browser/editor remains the default experience.

Native automation — implemented, opt-in

… → Automations composes existing public registerJob jobs; there is no new JavaScript registration, event emitter, permission, filesystem grant or background write capability. Database Tools and Analytics use the same public job API. Example: enable Analytics and Database Tools, create a rule for File imported matching raw_data, add Run Pipeline then Sample Storage, and enable it for the current database. Importing CSV queues the rule. Choose Run Pending Chain, inspect Pipeline's complete materialization, confirm Snapshot and Save, and the read-only sampler runs only after the save succeeds.

Rules bind to the canonical file path plus device/inode/creation identity and each job's approved package digest. Preferences live in Application Support/Petti/Automation, never in the source database. Each file has at most 16 rules, each with 1–8 ordered registered jobs; one rule is at most 32 KiB and the whole file at most 512 KiB. Invalid, oversized, linked or duplicate preferences are rejected. Replaced/renamed files do not inherit another file's rules. Rule preferences persist; pending/running executions and step results do not. Opening an ordinary file reads only bounded host preferences and does not execute jobs, adopt an App, inspect large tables or write database metadata.

Initial triggers:

Conditions are deliberately simple exact table/job matching; there is no expression interpreter. Events are a bounded 32-notification native stream and coalesce to at most one pending request per rule (16 total). Busy bursts/disabled rules/chain-origin completions never create an unlimited queue. Rules are local to an open database window; no cross-process scheduler or exactly-once execution guarantee is implied.

Execution is attended: events only queue work and show a subtle notice. The user starts a chain. Read jobs run sequentially with actual progress/logs. Each write step stops at the existing immutable review with source revision checks, actual RO/license enforcement, recovery snapshot and atomic transaction; it cannot silently save because a rule exists. Empty write plans need explicit Continue and make no write. Steps use registered default field inputs; the first native rule editor does not persist custom input editing. Final per-step summaries are bounded to 4,096 characters, at most eight; detailed mutation/recovery records remain in Plugin Edit History.

Failure, cancel, package revocation, database replacement or closing the panel stops the chain. An accepted commit remains committed; previous successful steps are not rolled back with later steps. There is no automatic retry, resume-after-restart, unattended commit, schedule or cross-step transaction. Changing a plugin digest invalidates a saved step and requires recreating the rule after plugin approval. Disabling/deleting rules remains available even if their plugin is unavailable. View Raw Database/Escape remains an obvious exit, with normal browsing refreshed after attempted writes.

Runner process resource containment — implemented

All invocations now share a host-process admission ceiling of four concurrent runners (excess requests fail visibly, without an unbounded waiting queue). Each child has existing CPU limits, a 512-KiB RLIMIT_FSIZE, 64-descriptor RLIMIT_NOFILE, and disabled core dumps. Failure to set a limit stops the runner before evaluating plugin code.

A native timer independent of awaited database callbacks samples child physical footprint every 20 ms, stops a child over 256 MiB, and enforces the invocation wall deadline. Repeated unavailable accounting also stops execution. This is a watchdog, not a hard heap quota: allocations can overshoot between samples or scheduler delays. Four admitted children are not an aggregate resident-memory reservation. Tests use a lower 64-MiB threshold and accept either allocator rejection or watchdog termination; a subsequent invocation still works.

These process limits complement the fixed allocator and OS sandbox below. See PLUGIN_ISOLATION_NOTES.md for the native proof and remaining release matrix.

Fixed allocation and OS isolation — implemented

The engine-only QuickJS dependency is limited to the helper. Its custom allocator reserves exactly 64 MiB per invocation, splits/coalesces blocks inside that arena and fails allocations when exhausted; it never falls back to the system allocator. Runtime/context, strings, objects, ArrayBuffer/SharedArrayBuffer and pending Promise jobs use it. A 512-KiB JavaScript stack limit and interrupt deadline complement kernel CPU/process/file/descriptor limits. Four-runner admission and the independent footprint watchdog remain in force; neither is described as a hard whole-process RSS cap. Native Foundation/code/stack and bounded wire copies exist outside the arena.

The only sandbox entitlement is App Sandbox. No JIT, network or user-selected-file entitlement is granted to the helper. The host provides approved values over pipes, never database handles, raw file descriptors to databases or filesystem paths as capabilities. Frames have distinct kinds and preallocation length limits: request/final 512 KiB, database call 128 KiB, reply 256 KiB, event 4 KiB. Events require acknowledgements; calls cannot overlap. Existing eight-call/128-event/deadline limits apply. Plugin installation is inert; explicit Enable binds approval to package content. Removal, disable, changed content, database closure and unsafe helper signatures invalidate execution.

Native sandbox probes verify denied reads/writes outside the code directory, network connection and fork on the current host. The OS may permit public system/code resources; this is not a promise that the sandbox sees no files. No network/native module bridge is exposed. See PLUGIN_ISOLATION_NOTES.md for evidence and supported-OS release limits. The documentation syntax checker still uses JavaScriptCore only to parse examples; actual plugin execution and compatibility tests use QuickJS.