Files
llm/docs/1c-write-path-safety.md

689 lines
34 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 1C Write Path Safety
Status: active configuration writes are blocked by design. Saved-state form
writes are allowed only through the explicit proposal/apply gates described
below.
Layer and addressing work plan: `docs/1c-extension-layer-plan.md`.
The adapter may read SQL, Config, ConfigCAS, XML exports, and extension
manifests. It must not write active SQL metadata/data tables or active Config
payloads until the following gates are implemented and tested. The only current
write exception is saved-state form payload editing in `ConfigSave` and
`ConfigCASSave`.
## Required Gates
1. Backup gate
Every write run must record database, configuration state, extension state,
SQL transaction boundary, and rollback instructions before changing
anything.
2. Round-trip parser gate
The adapter must decode target payload, encode it back without semantic
changes, and prove byte/structural equivalence where applicable.
3. Designer validation gate
Any metadata write must be validated by 1C tools, not only by SQL shape.
4. Saved-state gate
The adapter must distinguish active configuration from saved but not applied
state, including `ConfigSave` and extension saved-state equivalents.
5. Extension packaging gate
Extension changes must preserve manifest/CAS relationships and validate
extension load/update behavior.
6. Diff gate
Every proposed write must produce a human-readable diff at metadata level and
physical storage level.
7. Minimal write scope gate
Writes must target the smallest possible object or payload. Bulk Config
rewrites are forbidden unless no narrower route exists.
8. Recovery test gate
Restore/rollback must be tested on a disposable database before production
write support is enabled.
9. Full-path target gate
Every write intent must resolve to a full 1C path or a concrete saved-state
reference before planning. Short names and local BSL symbols are acceptable
only after they are bound to an object, form, module, routine, or query
context.
10. Layer provenance gate
Every write plan must identify whether the effective element comes from the
base configuration, a selected extension, generated extension source, or a
saved-state working copy. Effective text alone is not a writable target.
11. Extension control gate
Extension code operations must classify insert-before, insert-after,
replace, and replace-with-control separately. Replace-with-control writes
must verify the controlled fragment against the current source before apply.
## Allowed Now
- read metadata from SQL/XML/CAS;
- build object read views;
- resolve references;
- inspect forms/modules/templates;
- generate proposed code or metadata patches as files;
- produce diffs and implementation plans;
- plan saved-state form edits with `metadata.write` or
`metadata.form.element.write`;
- apply saved-state form proposals only when all are true:
`allow_sql_saved_state_apply=true`, the proposal contains
`validation.mode=path_preserve_format`, current sha1 matches, and backup
evidence is written before update;
- rollback saved-state form writes through `storage.saved_state.rollback`.
- build read-only write plans that resolve full 1C paths, provenance, and
required guards before a future apply route is selected.
- use `metadata.write` as the normal agent-facing BSL write facade. It accepts
canonical 1C paths, module/routine names, and code text, defaults to saving
into `ConfigSave`/`ConfigCASSave`, and hides physical storage details unless
`include_storage=true`. `code.write` remains a compatibility shortcut for
simple module edits.
## Forbidden Now
- direct updates to `_Reference*`, `_Document*`, register tables, `Config`,
`ConfigCAS`, or active extension CAS tables;
- saved-state writes that rewrite the whole serialized form payload instead of
patching exact scalar token spans;
- agent-facing code writes that require callers to know SQL tables, file names,
stream indexes, or brace paths;
- automatic Designer update/apply;
- extension rebuild/writeback;
- direct writes to effective views without layer provenance and write-plan
evidence;
- treating a local BSL expression such as `<Переменная>.<Поле>` as a metadata
path without code-symbol resolution in the current context;
- any write action that does not have rollback evidence.
## Saved-State Form Write Contract
Saved-state form writes must:
- resolve the display source before editing. If an element caption is empty and
inherited from a linked command, write the command caption, not the empty
element caption;
- use the form property registry for aliases and verification behavior instead
of ad-hoc property matching;
- return registry metadata for decoded parameter properties, including stable
canonical names such as `visible`, `enabled`, and `command_bar_location`;
- avoid silently writing a local element title when the displayed caption is
derived from `ПутьКДанным`; local title override must be explicit with
`source=local_override`;
- when an empty element title is derived through `ПутьКДанным`, route by source:
form attribute -> write the form attribute title; tabular form attribute field
such as `ТЗ.К1` -> write the field title; object/configuration attribute
(`Объект.<Реквизит>`) -> write the local form element title;
- patch a single scalar brace token in the original decoded text and preserve
surrounding formatting/compression envelope;
- return a semantic diff and physical sha1/byte metadata before apply;
- create rollback evidence under `ONEC_ADAPTER_BACKUP_DIR` or
`/data/adapter-apply-backups`;
- verify readback sha1 and re-decode the form for semantic verification.
## Saved-State BSL Write Contract
Agents edit modules as text, at the same level as a human programmer:
- read current code from the working/save state;
- replace the whole module with `module_text`, `full_text`, or `code`;
- replace one procedure/function with `routine_name` and `routine_text`;
- replace a smaller fragment with `old` and `new`. If `routine_name` or a
routine-level `canonical_path` is provided, uniqueness is checked inside that
procedure/function; otherwise `old` must occur exactly once in the current
saved module text.
The adapter maps that request to the physical saved-state route. For ordinary
module streams it uses the saved-state module writer. For embedded form modules
it patches only the module scalar token in the form payload and verifies that
the encoded payload changed no more than that token. If a fragment repeats in
the selected scope, the adapter must return `ambiguous_fragment` with `scope`
and `counts.occurrences` instead of choosing an occurrence.
## Saved-State Preparation Gate
An empty `ConfigSave` or `ConfigCASSave` means there are no unactivated
Configurator changes in that save layer. To test or apply saved-state writes
for an object, first prepare a working copy from the active storage family:
`Config -> ConfigSave` for base configuration objects, or
`ConfigCAS -> ConfigCASSave` for extension/CAS objects.
The preparation flow is intentionally split into reviewable stages:
```text
python scripts/plan_1c_saved_state_copy.py --base-id upo_test --target-table ConfigSave --report reports/1c-sql/upo_test/saved-state-copy-plan.json --json
python scripts/prepare_1c_saved_state_copy_sql.py --plan reports/1c-sql/upo_test/saved-state-copy-plan.json --expected-base-id upo_test --expected-target-table ConfigSave --sql-out reports/1c-sql/upo_test/prepare-saved-state-copy.sql --report reports/1c-sql/upo_test/prepare-saved-state-copy-sql.json --json
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\execute_1c_saved_state_copy_sql.ps1 -ExpectedBaseId upo_test -ExpectedTargetTable ConfigSave -IUnderstandThisWritesToSql
python scripts/verify_1c_saved_state_copy.py --plan reports/1c-sql/upo_test/saved-state-copy-plan.json --expected-base-id upo_test --expected-target-table ConfigSave --require-ready --json
```
Only `execute_1c_saved_state_copy_sql.ps1` performs SQL writes, and it requires
the explicit `-IUnderstandThisWritesToSql` gate. Normal adapter verification
generates the plan and reviewed SQL artifacts but does not execute them.
## Saved-State Write Route Smoke
After changing saved-state form write routing or redeploying the REST adapter,
run:
```text
python scripts/smoke_1c_saved_state_write_routes.py \
--report reports/1c-sql/upo_test/saved-state-write-routes-smoke.json
```
The smoke test uses only the REST adapter URL and performs `apply_and_rollback`
against `upo_test`. It must verify these route families:
- form element with `ПутьКДанным = А` writes the form attribute title;
- form element with `ПутьКДанным = ТЗ.К1` writes the tabular form attribute
field title;
- `command=КомандаПример1` writes the command title directly.
## Saved-State Write Matrix
To automatically enumerate decoded scalar values and learn which ones can be
changed safely, use the write matrix methods:
```text
python scripts/smoke_1c_write_matrix.py \
--max-candidates 50 \
--report reports/1c-sql/upo_test/write-matrix-smoke-50.json
```
For a full verified registry on the current `upo_test` learning form:
```text
python scripts/smoke_1c_write_matrix.py \
--max-candidates 1000 \
--report reports/1c-sql/upo_test/write-matrix-smoke-full.json
python scripts/build_1c_write_matrix_verified_registry.py \
--smoke-report reports/1c-sql/upo_test/write-matrix-smoke-full.json \
--output reports/1c-sql/upo_test/write-matrix-verified-registry.json \
--include-route-evidence
python scripts/build_1c_write_matrix_enum_registry.py \
--matrix-report reports/1c-sql/upo_test/write-matrix-build-v4.json \
--output reports/1c-sql/upo_test/write-matrix-enum-registry.json
python scripts/build_1c_write_matrix_scalar_registry.py \
--matrix-report reports/1c-sql/upo_test/write-matrix-build-v4.json \
--output reports/1c-sql/upo_test/write-matrix-scalar-registry.json
python scripts/build_1c_write_learning_plan.py \
--registry reports/1c-sql/upo_test/write-matrix-scalar-registry.json \
--output reports/1c-sql/upo_test/write-learning-plan-scalar.json
python scripts/analyze_1c_write_matrix_structural_diff.py \
--before reports/1c-sql/upo_test/write-matrix-build-v4.json \
--after reports/1c-sql/upo_test/write-matrix-build-after-move-a.json \
--output reports/1c-sql/upo_test/write-matrix-structural-diff-move-a-after-b.json
```
```json
{"method":"metadata.form.write_matrix.build","payload":{"base_id":"upo_test","table":"ConfigCASSave","file_name":"<form-file-name>"}}
```
```json
{"method":"metadata.form.write_matrix.smoke","payload":{"base_id":"upo_test","table":"ConfigCASSave","file_name":"<form-file-name>","allow_sql_saved_state_apply":true,"allow_sql_saved_state_rollback":true,"max_candidates":10,"learning_id":"upo-test-write-matrix"}}
```
`build` returns every decoded writable scalar candidate with `can_smoke` and a
reason when it is not safe for generic smoke. `smoke` runs only `can_smoke`
entries through `apply_and_rollback`, records verification status, and stores a
report under `ONEC_ADAPTER_WRITE_LEARNING_DIR` when `learning_id` is passed.
Matrix entries include `semantic_name`, `semantic_group`, and `semantic_source`
when the decoded form semantic map knows the parameter, so gap analysis can
group unresolved properties by configurator-facing meaning instead of only by
physical `Параметр N`.
For empty local strings, `build` also adds `codec_probe` diagnostics. Direct
empty `string` nodes are smoke-safe; composite empty string/list nodes remain
classified as `composite_node_requires_semantic_rule` after probing because
they usually represent typed structures such as layout, decoration, references,
or containers rather than plain strings.
Current generic smoke intentionally skips identity/binding values (`id`, `name`,
`ПутьКДанным`), enum/color values without known allowed sets, and composite
nodes that need a source-specific semantic write rule.
The scalar registry separates these skipped scalar values into learning queues:
`run_before_after_learning_for_parameter` is safe to learn with manual
Configurator before/after captures, `learn_reference_write_rule` needs a
source-specific reference/container rule, and `do_not_generic_write` remains
manual-only because it covers identity or structural values.
The learning plan turns the scalar or enum registry into an ordered queue of
manual learning cases. Each case contains a selector, current value, physical
write path, and the workflow:
`capture_before -> manual_configurator_change -> capture_after -> diff ->
infer_rule -> smoke_rule`.
Moving form elements is a structural operation, not a scalar property write.
`metadata.write_learning.diff` reports these as `target_moves` by stable target
identity (`section`, `name`, `id`) when a target path changes. Property
inference then returns `structural_move_rule_required` until a dedicated
saved-state move/reorder writer is implemented and smoke-tested.
The first structural writer is `metadata.form.target.move`. It currently
supports the learned safe primitive `swap_sibling_slots`: two form item nodes in
the same parent container are swapped by byte-preserving brace-text ranges
(`swap_paths`) and then passed through the same saved-state proposal,
backup/apply, and rollback gates as scalar writes. Example:
```json
{"method":"metadata.form.target.move","payload":{"base_id":"upo_test","table":"ConfigCASSave","file_name":"<form-file-name>","from_element":"А","to_element":"Б","allow_saved_state_write":true,"mode":"apply_and_rollback","allow_sql_saved_state_apply":true,"allow_sql_saved_state_rollback":true}}
```
The second structural primitive is `append_child`, used by
`metadata.form.command_button.write` to add a form command and a visible command
bar button. The method clones existing command/button nodes from the same form,
replaces name/title/action/GUID fields, appends the new nodes with
byte-preserving brace-text insertion, updates declared section counts such as
`{marker,count,record...}` when the append target uses them, then uses the same
proposal, backup, apply, verify, and rollback gates. If the save layer is empty, `plan` exposes
`metadata.saved_state.prepare`; apply modes with `allow_sql_saved_state_apply`
prepare saved state internally before retrying.
After `apply`/`apply_and_verify`, the method re-decodes the saved-state form
and returns `semantic_verify`: command present, button present, handler routine
present, command->handler link, button->command link, and the decoded
command/button paths. Repeated `upsert` calls are idempotent: if command and
button already exist, the method reports `idempotency.status=already_exists`,
updates/verifies the handler, and does not append duplicate structural nodes.
Successful saved-state module changes refresh `metadata_code_index_cache` and
vector chunks for the embedded form module, while SQL remains the freshness
source of truth.
Use `metadata.form.command_button.verify` when no write is needed. It accepts
the same public selectors (`extension`, `form`, `command_name`, `button_name`,
`handler_name`) and returns the same command/button/handler/link checks from
current SQL saved-state. Public command/button write and verify responses keep
saved-state search compact: `counts` plus `selected_form`, not the full list of
similar form candidates. Public form node addresses are exposed as `form_path`;
physical SQL `file_name` remains opt-in through `include_storage=true`.
By default the same method also upserts the form-module handler routine named by
`command_action`. For SQL form payloads where the module is embedded as a scalar
BSL string, the writer edits module path `2` with `replace_routine_text` and a
byte-preserving scalar proposal. Pass `include_handler=false` only for
diagnostics that intentionally validate the structural form append alone.
```json
{"method":"metadata.form.command_button.write","payload":{"base_id":"upo_test","extension":"test2","object_type":"CommonForm","object_name":"t_Форма","command_name":"РасчетС","command_title":"РасчетС","command_action":"РасчетС","allow_saved_state_write":true,"mode":"plan"}}
```
For ordinary agent work, prefer the high-level `metadata.write` entrypoint when
only the form module routine must be changed. It resolves
`ОбщаяФорма.<Form>.<Routine>` to the saved-state form payload, treats missing
routine names in `upsert` mode as an owner-only module search, edits embedded
module path `2`, and keeps the save-first defaults on apply modes:
```json
{"method":"metadata.write","payload":{"base_id":"upo_test","target":{"canonical_path":"ОбщаяФорма.t_Форма.РасчетС"},"mode":"apply_and_rollback","routine_operation":"upsert","routine_text":"&НаКлиенте\nПроцедура РасчетС(Команда)\n\t// ...\nКонецПроцедуры\n","allow_sql_saved_state_rollback":true}}
```
For form commands/buttons, `metadata.write` can route explicit paths such as
`ОбщаяФорма.<Form>.Команда.<Command>` or
`ОбщаяФорма.<Form>.Кнопка.<Button>` to
`metadata.form.command_button.write`. For existing form properties, use
`metadata.form.write_target.verify` first; it reports whether the saved-state
target is writable now or whether the adapter would need to prepare Save.
Every write call made through the public adapter entrypoint, including
`code.write`, receives an `operation_id`. Use `metadata.write.history` to
retrieve recent operations, backup ids, routed method, and verification result.
History accepts `operation_method`, `status`, `routed_method`, and `backup_id`
filters for quick audit lookup. Pass `include_summary=true` to get aggregate
counts by method, status, routed method, and operations with backups.
Use `metadata.write.rollback` to rollback by `operation_id` or `backup_id`;
it still requires the explicit `allow_sql_saved_state_rollback=true` gate.
Deployment verification runs `scripts/smoke_1c_write_rollback_safety.py` for
REST and MCP. The smoke does not apply rollback; it checks that
`metadata.write.rollback` is exposed, `metadata.write.history` is readable, and
rollback without the explicit gate returns `invalid_argument` for
`allow_sql_saved_state_rollback`.
To inspect what is currently pending in Save, use `metadata.saved_state.diff`.
It is read-only and compares the saved payload with its active SQL source:
```json
{"method":"metadata.saved_state.diff","payload":{"base_id":"upo_test","table":"ConfigCASSave","file_name":"<saved-state-file-name>","max_text_diff_lines":80}}
```
If the saved payload is missing, the result is `status=not_found` with
`needs_prepare=true` and a `metadata.saved_state.prepare` plan payload. If both
layers exist, the result is `changed` or `unchanged` with live SQL hashes.
Deployment verification runs `scripts/smoke_1c_saved_state_diff.py` for REST
and MCP and persists `saved-state-diff-smoke.json` plus
`saved-state-diff-mcp-smoke.json`.
For a save-layer overview, use `metadata.saved_state.status` before drilling
into a single file:
```json
{"method":"metadata.saved_state.status","payload":{"base_id":"upo_test","table":"ConfigCASSave","limit":500}}
```
It returns row/file counts and classifies files as `changed`, `unchanged`, or
`saved_only`. Each file includes a `diff_selector` for `metadata.saved_state.diff`.
For readback by public name, `metadata.form.decode` honors
`source_state=working`/`state=save`: it searches `ConfigCASSave`/`ConfigSave`
first and uses `extension` to narrow extension forms before falling back to
active metadata routes.
## Saved-State Copy Learning
An empty `ConfigSave`/`ConfigCASSave` means there is no pending Configurator
working copy. It does not mean the object is missing; the active object remains
in `Config`/`ConfigCAS`. To change an object through the saved-state write
path, first copy that same target object from the main configuration storage
into the save layer (`ConfigSave`/`ConfigCASSave`), then apply changes to the
save object and keep rollback evidence. To learn or verify the copy shape
safely, take a save-layer snapshot, create one minimal pending Configurator
change without applying it, then take another snapshot and compare the created
rows.
Before preparing a strict write smoke, generate a read-only copy plan:
```text
python scripts/plan_1c_saved_state_copy.py \
--base-id upo_test \
--target-table ConfigSave \
--report reports/1c-sql/upo_test/saved-state-copy-plan.json
```
The plan must be `plan_ready`, list active source rows from the matching storage
family, and report `target_collisions.status=clear`: `Config -> ConfigSave` for
base objects, `ConfigCAS -> ConfigCASSave` for extension/CAS objects. The
persisted report validator checks this plan by default, so a stale, colliding,
or family-mismatched save-layer target fails before any strict write smoke is
enabled.
After reviewing the copy plan, generate a guarded SQL preparation script without
executing it:
```text
python scripts/prepare_1c_saved_state_copy_sql.py \
--plan reports/1c-sql/upo_test/saved-state-copy-plan.json \
--expected-base-id upo_test \
--expected-target-table ConfigSave \
--sql-out reports/1c-sql/upo_test/prepare-saved-state-copy.sql \
--report reports/1c-sql/upo_test/prepare-saved-state-copy-sql.json
```
The generated script repeats the collision check inside a transaction, copies
only the reviewed `FileName` values, verifies the inserted row count, and
commits only if those guards pass.
After the SQL preparation is executed, verify that the save layer contains the
reviewed bytes:
```text
python scripts/verify_1c_saved_state_copy.py \
--plan reports/1c-sql/upo_test/saved-state-copy-plan.json \
--expected-base-id upo_test \
--expected-target-table ConfigSave \
--report reports/1c-sql/upo_test/saved-state-copy-verify.json \
--require-ready
```
Before executing the SQL preparation this should fail with
`blocked_missing_target_rows`; after preparation it must pass before strict
readiness or write-and-rollback smoke is enabled.
Generate the guarded cleanup script before executing the preparation SQL:
```text
python scripts/prepare_1c_saved_state_cleanup_sql.py \
--plan reports/1c-sql/upo_test/saved-state-copy-plan.json \
--expected-base-id upo_test \
--expected-target-table ConfigSave \
--sql-out reports/1c-sql/upo_test/cleanup-saved-state-copy.sql \
--report reports/1c-sql/upo_test/cleanup-saved-state-copy-sql.json
```
The cleanup script deletes only the reviewed `FileName`/`PartNo` rows from
`ConfigSave`, checks their `BinarySHA1` first, and rolls back if the save-layer
rows no longer match the plan.
Check strict readiness against the same target table:
```text
python scripts/check_1c_saved_state_strict_readiness.py \
--base-id upo_test \
--saved-state-table ConfigSave \
--report reports/1c-sql/upo_test/saved-state-strict-readiness.json
```
Observed on `upo_test`:
- a saved form edit created three `ConfigCASSave` rows:
`<object_guid>__<form_guid>`, `<object_guid>__<form_guid>.0`, and
`<object_guid>__configinfo`;
- a saved object-module edit added two module rows:
`<object_guid>__<module_guid>` and `<object_guid>__<module_guid>.0`, and
updated `<object_guid>__configinfo`;
- `ConfigSave` remained empty for these cases.
Direct saved-state module reads should use a concrete stream when known:
```json
{"method":"modules.read","payload":{"base_id":"upo_test","module_ref":"ConfigCASSave:<object_guid>__<module_guid>.0#stream:4","preview":true}}
```
For direct `ConfigSave`/`ConfigCASSave` module refs the adapter uses a synthetic
saved-state owner context and skips extension owner scans.
Do not pass a saved-state form payload container such as
`ConfigCASSave:<extension_guid>__<form_guid>.0` to
`metadata.module.write_apply` as if it were a concrete BSL stream. That ref is
readable for embedded form module analysis, but stream writes require
`#stream:<index>` or an explicit `stream_index`. If the ref has no stream index,
`metadata.write.plan` must return `ready_for_apply_method=false` for the stream
writer and include `next_resolution`.
The high-level `metadata.write` entrypoint may still accept such a saved-state
form payload container when the request identifies one routine with
`routine_name` and `routine_text`. In that case it must route to the embedded
form payload writer (`form_embedded_module_handler_write_apply`), edit the BSL
text at the decoded form module path, and preserve the payload format instead
of rewriting the serialized form payload as plain module text. For routine
replacement in form modules, existing leading BSL directives such as
`&НаКлиенте` are preserved when the replacement routine text omits a directive.
When the concrete module stream is not known, search saved-state modules first:
```json
{"method":"metadata.saved_state.modules.search","payload":{"base_id":"upo_test","tables":["ConfigCASSave"],"owner_guid":"<object_guid>","query":"<text-fragment>","limit":10}}
```
The search also accepts public object selectors. When
`object_type`/`object_name` resolve to one object, the adapter narrows the
saved-state scan by that owner GUID:
```json
{"method":"metadata.saved_state.modules.search","payload":{"base_id":"upo_test","tables":["ConfigCASSave"],"object_type":"Catalog","object_name":"Номенклатура","query":"ПередЗаписью","limit":10}}
```
The search result returns `streams[].module_ref`, `streams[].write_plan_target`,
payload sha1, stream encoding, and a short preview. Use `write_plan_target` as
the concrete module target for `metadata.write.plan`, then use the accepted plan
with `metadata.module.write_apply` or the generic `metadata.write` module
target.
For saved-state form payloads, the search also detects the embedded form module
stored inside the decoded form payload. These rows return
`payload.role=form_embedded_module_payload`, `streams[].module_path`, and a
`streams[].write_plan_target.module_ref` without `#stream`. That target is for
the generic `metadata.write` embedded-form route, not for
`metadata.module.write_apply`. When `object_type=CommonForm` and
`object_name` are supplied, module search first resolves the exact saved-state
form file through `metadata.saved_state.forms.search`, so similarly named forms
such as `t_Форма` and `tt_Форма3` do not broaden the module scan.
The generic `metadata.write` module target can use that selector-only path
directly. A form routine replacement may pass `target.kind=module`,
`target.object_type=CommonForm`, `target.object_name`, `routine_name`, and
`routine_text` without a `module_ref`; the adapter resolves the saved form
file, detects `form_embedded_module_payload`, and routes to the embedded-form
writer.
For common forms, the same path is available from a canonical path such as
`ОбщаяФорма.t_Форма.ЗаменаДомена`. The planner classifies it as a module
routine on a common form (`path_kind=module_routine`,
`section=form_module`), and `metadata.write` may resolve it through
`metadata.saved_state.modules.search` before routing to the embedded-form
writer.
Form module payload writes must never canonicalize the whole form payload. A
prior unsafe write showed that replacing path `2` with a normal structural
`path` edit can make Designer reject the saved form even when the adapter can
decode it. Embedded form module apply is therefore guarded in two places:
`form_embedded_module_handler_write_apply` verifies that the encoded payload is
exactly one byte-preserving scalar replacement, and
`storage.saved_state.apply_proposal` blocks form module path `2` edits unless
their edit mode is `path_preserve_format`.
Some saved form module scalars contain a trailing container marker such as
`///----`. This is not agent-facing BSL text. Public `code.read` and
`code.search` hide the trailing marker, while the embedded form module writer
preserves it automatically when an agent replaces the full module text.
Saved-state module writes use stream edits, not brace-path edits. Smoke the
route with apply-and-rollback:
```text
python scripts/smoke_1c_saved_state_module_write.py \
--file-name <object_guid>__<module_guid>.0 \
--stream-index 4 \
--expected-sha1 <current-payload-sha1> \
--report reports/1c-sql/upo_test/module-stream-write-smoke-script.json
```
The proposal must validate the BSL stream, update the stream header, apply with
a saved-state backup, verify readback sha1, and rollback to the original sha1.
If the disposable base currently has no pending Configurator saved-state rows,
pass `--allow-empty-saved-state` so the smoke records
`skipped_no_saved_state` instead of reporting a false route failure. For a real
write test, prepare the save layer by copying the module owner object from
`Config`/`ConfigCAS` into `ConfigSave`/`ConfigCASSave` first.
Agents should use the high-level method once the module saved-state row already
exists:
```json
{"method":"metadata.module.write_apply","payload":{"base_id":"upo_test","module_ref":"ConfigCASSave:<object_guid>__<module_guid>.0#stream:4","allow_saved_state_write":true,"mode":"apply_and_rollback","allow_sql_saved_state_apply":true,"allow_sql_saved_state_rollback":true,"expected_sha1":"<current-payload-sha1>","old":"<old-fragment>","new":"<new-fragment>","expected_contains":"<guard-fragment>"}}
```
Module text can be changed at three levels:
- full stream replacement: pass `text`;
- fragment replacement: pass `old` and `new`;
- routine replacement/append/upsert: pass `routine_name`, `routine_operation`,
and `routine_text`.
For full stream replacement, pass `expected_text_sha1` from the current module
text in addition to `expected_sha1` for the SQL payload. `expected_text_sha1`
uses normalized line endings, matching the code index freshness hash. This lets
agents verify that the BSL text they read is still the text being patched even
when SQL payload metadata or compression details differ.
This is a thin orchestrator over the proven stream writer: it normalizes
`module_ref`, builds `changes.propose`, applies through
`storage.saved_state.apply_proposal`, and can immediately rollback for smoke.
Before SQL apply it also runs `metadata.write.plan`; blocked plans, such as
`replace_with_control` without a control fragment, stop before the storage
apply call. If current-source evidence such as `current_text` or `source_text`
is supplied, `replace_with_control` also blocks with `control_fragment_drift`
when the controlled fragment no longer appears in that source.
The generic write entrypoint routes module targets to the same method:
```json
{"method":"metadata.write","payload":{"base_id":"upo_test","target":{"kind":"module","module_ref":"ConfigCASSave:<object_guid>__<module_guid>.0#stream:4"},"mode":"apply_and_rollback","allow_sql_saved_state_apply":true,"allow_sql_saved_state_rollback":true,"expected_sha1":"<current-payload-sha1>","old":"<old-fragment>","new":"<new-fragment>","expected_contains":"<guard-fragment>"}}
```
If `module_ref` is omitted, `metadata.write` can resolve exactly one saved-state
module stream with `metadata.saved_state.modules.search`:
```json
{"method":"metadata.write","payload":{"base_id":"upo_test","target":{"kind":"module","owner_guid":"<object_guid>"},"mode":"apply_and_rollback","allow_sql_saved_state_apply":true,"allow_sql_saved_state_rollback":true,"old":"<old-fragment>","new":"<new-fragment>","expected_contains":"<guard-fragment>"}}
```
Ambiguous or missing searches are reported as `module_target_not_resolved`; pass
`module_ref`, `file_name`, `stream_index`, or a narrower query.
For agents, saved-state preparation is an adapter concern, not a planning
choice. The agent asks to change the current module/form. The adapter reads the
active source from `Config`/`ConfigCAS`, creates or reuses the corresponding
working copy in `ConfigSave`/`ConfigCASSave`, writes only that saved-state copy,
and leaves activation to a human in 1C.
For the high-level `metadata.write` entrypoint, apply modes are save-first by
default. Agents should not ask users for SQL permission flags when the user asks
to save/edit metadata: call `metadata.write` with `mode=apply` and the adapter
sets `allow_sql_saved_state_apply=true`, `allow_sql_saved_state_prepare=true`,
and `auto_prepare_saved_state=true` internally. Low-level storage/apply tools
remain explicit, but the agent-facing writer has exactly one write destination:
`ConfigSave`/`ConfigCASSave`.
When `metadata.write` runs in `plan` mode and the save layer is empty, it returns
a `metadata.saved_state.prepare` plan. When `metadata.write` runs in an apply
mode, saved-state preparation is automatic by default. If a caller passes an active module ref such as
`ConfigCAS:<file>#stream:<n>`, the adapter prepares the matching saved-state ref
such as `ConfigCASSave:<file>#stream:<n>` and continues there. Direct writes to
`Config`/`ConfigCAS` remain blocked. Pass `auto_prepare_saved_state=false` only
for diagnostic tests that must prove the save layer is already present.
The same rule applies to form writes. If `metadata.write` receives a form target
in `Config`/`ConfigCAS`, it prepares the matching `ConfigSave`/`ConfigCASSave`
row and then routes the scalar form edit to
`metadata.form.element.write_apply`. If the saved-state form row is missing, a
`plan` response exposes `metadata.saved_state.prepare`; an apply mode with
`metadata.write` performs that preparation internally and retries against the
save table.
Routine-level module edits can be passed without manually building the low-level
`routine` object:
```json
{"method":"metadata.write","payload":{"base_id":"upo_test","target":{"kind":"module","owner_guid":"<object_guid>"},"mode":"plan","routine_name":"<procedure-or-function>","routine_operation":"replace","routine_text":"<full procedure/function text>","expected_old_contains":"<guard-fragment>"}}
```
Supported `routine_operation` values are `replace`, `append`, and `upsert`.
The routine writer preserves the surrounding module bytes: it keeps the module
EOL style and replaces only the exact routine span, not the separators before or
after it. Routine `expected_old_sha1` is calculated over that exact routine span.
## Preferred First Write Path
The first production-safe write path should be extension-first:
```text
generate extension source
-> validate in 1C tooling
-> package extension
-> load into disposable base
-> run smoke tests
-> produce human approval diff
```
Direct SQL metadata writes remain a research path until all gates above are
green.