Save the current implementation and README, status, and known-issue documentation at the user-requested stop. No completed release or deployment is claimed. Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: GPT-6 Codex <noreply@openai.com>
11 KiB
Native record hooks
The host opts a table into phases during instance construction, before QQQ validation:
ScriptHooks.install(table, HookEvent.PRE_INSERT, HookEvent.PRE_UPDATE,
HookEvent.PRE_DELETE, HookEvent.POST_INSERT, HookEvent.POST_UPDATE,
HookEvent.POST_DELETE);
Installation preserves existing Java callbacks, runs each once before scripting within its phase, and is idempotent. It stages a new customizer map before replacing metadata. Non-Java callback references are refused. It does not mutate a global configuration singleton. A configured callback must remain trusted host Java; it is never a user-selected code reference.
Create bindings through ScriptingService.bind with current scripting.bind permission, an authorized publication, declared read grants, and explicit patchable fields. A binding pins its immutable publication. A new publication does not move it. Every execution reloads the active binding and verifies its artifact. Generic binding tables are service-write-only. The optional middleware and source editor expose semantic create/enable/disable actions; public Java service calls enforce the same current target read/write permissions, installed phase and patch-field policy. Binding changes and their attributed BINDING_CHANGE receipts commit atomically. Disabling requires current target read permission and project/BIND authority; a removed phase does not prevent disabling it.
Binding scope: a binding is administrative deployment authority controlled by scripting.bind and applies to ALL records written to its table and event in the configured application namespace. It is not filtered by the project's owner or tenant, and project authoring privacy is not an execution predicate. Execution uses the current record actor's permissions and the binding's declared capabilities. The native binding routes (POST .../bindings, .../bindings/{bindingId}/state) state this in their OpenAPI descriptions.
| Phase | Result | Native behavior |
|---|---|---|
| PRE_INSERT | ALLOW, REJECT, or permitted PATCH | Standalone hook runs BEFORE_ALL_VALIDATIONS; QQQ field behaviors, required/PVS, unique, reference and record-security checks follow. |
| PRE_UPDATE | ALLOW, REJECT, or permitted PATCH | Requires reviewed core early-update phase. Stored identities are authorized first; then native field behaviors and all ordinary validations process the patch. |
| PRE_DELETE | ALLOW or REJECT | Uses selected stored values; PATCH is invalid. |
| POST_INSERT / POST_UPDATE / POST_DELETE | ALLOW only | Runs after backend action; the caller transaction may still roll back. PATCH/REJECT is INVALID, creates a visible warning, and does not undo the action. |
When an existing host callback uses a later insert/update phase, composition preserves it. Patches are unsupported there: binding creation rejects declared patch fields, and runtime rechecks the phase so later metadata changes cannot bypass validation. Veto remains supported. Hosts must explicitly opt trusted callbacks into an early phase if they require script patches. Host validators that must inspect final patched values should use the native subsequent validation mechanisms; existing host callbacks are not secretly rerun.
Patch values are JSON scalars converted strictly to declared QQQ field types. Primary keys, hidden/noneditable fields, record security lock fields and fields denied by current field policy cannot be patched. Native QQQ owns field constraints and behaviors. Update patches do not run a private substitute validation pipeline.
The core additive update contract is TableCustomizerInterface.whenToRunPreUpdate(UpdateInput, boolean), returning PreUpdatePhase.BEFORE_FIELD_VALIDATIONS or the default AFTER_ALL_VALIDATIONS. Defaults retain existing host scheduling. Phase selection must be deterministic and free of side effects: UpdateAction selects it once per validation pass; bulk preview routing may consult it beforehand. Early callbacks require stored-record prefetch, receive defensive request/table/record copies of authorized error-free values and copied prior snapshots, and cannot add, remove, replace or retarget identities or associations. Current field and table permission checks precede copy-back. Public previews refresh stored rows rather than trusting caller-supplied old values. Backends without prefetch fail closed for early callbacks. Table and instance callbacks retain their order within each phase.
Context and authority
Worker main receives {event, payload:{proposed, prior?}}. PRE_UPDATE prior is obtained through an enforced native Get in the action transaction, not accepted from a supplied old-record list. POST_INSERT and POST_UPDATE proposed values come from the current authorized stored row; POST_UPDATE deliberately omits unchecked prior values. PRE_DELETE uses an authorized current row; POST_DELETE uses the pre-deletion authorized snapshot described below. Values are bounded JSON with no session, Java object or credentials. Hidden/password/blob fields and fields the actor cannot read are excluded. Read grants remain bounded, read-only and subject to current initiating-user broker policy. The original native action transaction is not committed by scripting.
Current table write permission, identity and context are rechecked around execution. Stored update/delete/post contexts additionally require genuine read-action permission checks and native record visibility. GetAction uses explicit ENFORCE, preserves the original extensible input source and effective action transaction, and suppresses display URLs/heavy content. The input class seen by host custom permission checkers is GetInput. Post rows failing read-only security locks never reach a worker. Insert contexts derived by a composed host callback require table READ permission; field read filtering remains separate. A read customizer cannot recursively invoke a script while constructing hook context. Nested execution from a worker callback is refused across scripting service instances on the same thread. Capability callbacks remain synchronous and bound to the initiating thread/session. An enabled hook fails closed on pre-action failures or oversized batches. No enabled binding means no script execution. Preview pre hooks may execute because capabilities are read-only; execution history is not evidence of an actual write.
POST_DELETE installation automatically composes PRE_DELETE capture, even if no pre-delete script is bound. Captures hold bounded JSON and scalar primary keys in QContext, keyed by exact DeleteInput identity, table, effective transaction, original actor/session/instance and thread. Keys are limited to 1024 encoded bytes; payload plus keys is limited to 256KiB across at most eight pending inputs, with the normal per-hook record limit. The post callback consumes the capture once and processes only native successful-deletion IDs. Retries and previews discard any prior same-input capture; preview never captures. All-pre-veto paths discard immediately. Backend failures that never invoke post callbacks may retain bounded entries until context teardown; they cannot match a new input. Serialized/asynchronous contexts cannot carry captures.
Authorization for a deleted row was established before deletion. Post serialization rechecks current table/credential/channel and field read policy; it never pretends to read a row that no longer exists. A capture/read/budget refusal alone does not veto deletion for a post-only binding: the post callback skips the worker, records a generic refusal and returns a warning. An actual PRE_DELETE binding independently authorizes its context and may veto.
Audit and failure semantics
STARTED and terminal execution audit entries commit independently of the business transaction. They report script execution, not application of its patch or commitment of the record. A successful post observation can coexist with a later rollback; JDBC tests prove this. Audit failures are surfaced, never converted into a success. If durable storage itself is unavailable it cannot manufacture a durable failure receipt. Post failures become visible warnings because the backend action may already have occurred; their script attempts are independently audited where audit storage is available. Context admission refusals append a separate HOOK_REFUSAL / DENIED / CONTEXT_REFUSED history row with actual actor and current binding/publication/build identity, without record values or fabricated STARTED/work execution. Normal project-scoped history visibility applies. Each retry is a new admission attempt. Refusal-audit persistence failure is explicitly reported; it cannot manufacture a receipt or undo the backend action.
These synchronous callbacks do not implement durable after-commit execution. The separate ScriptAfterHooks adapter uses native transactional Jobs; see DURABLE-AFTER-HOOKS.md for committed observation semantics and its verification status. Scripts cannot send messages, write arbitrary records, print, or perform other side effects through the read-only broker.
Authoring and dry runs
The source editor separates publication from activation. Its hook panel discovers only host-installed phases permitted to the current actor, creates an enabled binding against an immutable publication, and explicitly enables/disables retained bindings. Bindings are paged by project across all publications; an old active binding does not disappear when recent source history exceeds its window. Read grants must be a subset of the publication's declared capabilities. Binding names, grants and patch fields are bounded.
Hook tests require TEST, project/build association, current target read/event-write permissions and a selected record. The service reads that record through the same enforced native Get and field projection as hooks, accepts only bounded scalar proposed changes for pre-insert/update, and rechecks the row after execution before returning a decision. Tests never invoke a native write or apply patches. Insert/delete tests are simulations using the selected current row, not evidence of an insert/deletion; native write validations are not simulated. The qualified external worker and normal read-only capability broker remain mandatory.
Execution failure summaries and history contain only host-defined outcome enums and generic text. Worker error codes/messages and transport exception details are untrusted and never persisted or returned through these execution diagnostics. Compiler diagnostics concern authored source and use their separate bounded source-authorized response.
Qualification
Tests use a trusted protocol fixture that never executes script source. Native action, authorization, validation, recursion and JDBC rollback tests are local evidence only. Real hostile-worker deployment qualification remains a release gate. Native storage qualification covers H2/MySQL; PostgreSQL is not a supported provider in this QQQ baseline. Existing MySQL installations must widen the scripting_binding event constraint before enabling AFTER; the current bootstrap lists all nine phases. The six synchronous phases retain their existing meaning.