Files
llm/docs/1c-write-handler-architecture.md
2026-08-14 09:40:51 +03:00

122 lines
5.3 KiB
Markdown

# 1C Adapter: universal write dispatcher and typed handlers
## Decision
The public write contract stays universal and name-first:
```text
code.write / metadata.write
-> resolve public object and extension layer
-> plan and gates
-> select one typed handler
-> prepare saved state internally when required
-> apply, SQL-readback, rollback evidence
```
The caller never selects an SQL table, saved file, stream, payload codec, or
handler. If no handler has a proven capability for the requested object type
and operation, the dispatcher returns `unsupported_write_target` with a public
explanation. It must not fall back to a generic byte rewrite.
## Current state
The behaviour is already logically separated, but is physically concentrated
in `plugins/1c/connector/adapter_1c_server.py` (about 3.5 MB). The main
dispatcher is `metadata_write` and currently branches to:
| Public target | Existing internal writer |
|---|---|
| BSL module | `metadata_module_write_apply` |
| BSL embedded in a managed form | `form_embedded_module_handler_write_apply` |
| Form element/property | `metadata_form_element_write_apply` |
| Form command/button caption | `metadata_form_command_button_write` |
| Scalar object/member property | `metadata_object_property_write` |
| Add object member | `metadata_object_member_add` |
| Scheduled-job schedule | `metadata_scheduled_job_schedule_write` |
This is a suitable functional base. The problem is coupling: routing,
saved-state preparation, result shaping, codecs, SQL writes, and HTTP/RPC
dispatch live in one module, so a change in one type is too likely to affect
another.
## Target module layout
```text
plugins/1c/connector/
adapter_1c_server.py # HTTP, RPC registration, composition root only
write/
contracts.py # WriteIntent, WritePlan, WriteResult, capability errors
dispatcher.py # universal metadata.write dispatch; no SQL codecs
gates.py # layer, repository, optimistic-hash and mode gates
saved_state.py # Config→ConfigSave / ConfigCAS→ConfigCASSave prepare + receipt rollback
registry.py # handler registration and deterministic selection
handlers/
module.py
embedded_form_module.py
form_element.py
form_command.py
object_property.py
object_member.py
scheduled_job.py
unsupported.py
storage/
sql_saved_state.py # transactions, guarded row copy, backup/readback
extension_routes.py # active-to-saved route and cache refresh
```
`parser/` remains the place for pure payload decoding/encoding. A handler may
use a parser codec only where its round-trip proof exists; SQL access is
provided through a narrow context rather than imported globals.
## Handler contract
Each handler implements the same four operations:
1. `can_handle(intent, evidence) -> supported | unsupported | ambiguous`.
2. `plan(intent, evidence) -> WritePlan` with exact guards and no mutation.
3. `apply(plan, context) -> WriteResult` only after shared gates succeed.
4. `rollback(result, context)` when the handler created reversible state.
The dispatcher selects exactly one handler. Zero handlers yields
`unsupported_write_target`; multiple handlers yield `ambiguous_write_handler`.
Handlers never select another extension layer after dispatch.
`metadata.write.capabilities` also returns `registered_handlers`. This makes
the runtime registry visible beside the broader capability matrix and prevents
an API claim from silently drifting away from the installed handlers.
## Invariants owned centrally
- public name/ref and extension scope resolve before handler selection;
- only saved layers are writable;
- saved-state preparation is internal and idempotent;
- optimistic hash, audit event, backup/receipt, SQL readback and rollback
policy are common infrastructure;
- SQL readback is not described as Configurator activation;
- low-level storage fields are redacted from name-first responses.
## Safe migration order
1. **Completed:** add storage-free `write/contracts.py`, typed handler
declarations under `write/handlers/`, and `write/registry.py`; connect
`metadata.write` to the registry while delegating to existing writers
unchanged. The registry has a deny-by-default result for unknown target
kinds.
2. **In progress:** add `write/context.py`; the scheduled-job route now enters
its typed handler through this explicit context. The handler still delegates
to the single existing implementation until its body moves in one change.
3. Move `saved_state.py` and `storage/sql_saved_state.py` first. The recently
proven hash-keyed extension prepare/rollback smoke is its acceptance test.
4. Extract the least coupled handlers: scheduled job, object property, object
member.
5. Extract form element and form command handlers.
6. Extract module and embedded-form-module handlers last; retain their exact
payload codec and paired-write tests.
7. Reduce `metadata_write` to validation, plan/gate orchestration and one
registry call. Keep the old public API names and response schema intact.
Do not split by copying code into parallel paths. Each extraction must move one
authoritative implementation, keep the existing tests green, and add one
handler-level `plan → apply_and_rollback → readback` test in `upo_test` when a
matching fixture exists.