socair airlock convert rebuilds a PyTorch checkpoint as a safetensors file
inside the airlock, without unpickling it, and signs a derivation record that
binds the output to the input. The output is a new artifact: it lands in
staging and is scanned, signed, and promoted like any other. Nothing about
the conversion authorizes it.
socair airlock convert <checkpoint> --attestation <attestation.dsse.json> --key <operator.key> [--store <path>]
What it converts
The Pickle opcode scan's grammar (socair-wo/1) models a checkpoint's
data.pkl as typed values and matches every storage to a record of exactly
its size. When the row PASSes, the grammar's model says, for every tensor,
its dtype, shape, strides, storage offset, and storage record. The converter
reads the tensors from that model (pickle.ReadStateDict) and copies each
tensor's bytes from its record. It runs no pickle, Python's or anyone's.
The rules are versioned as socair-convert/1:
| Input | Converted | Refused with the reason |
|---|---|---|
| Container | a torch.save zip (PyTorch's format since 1.6) | a legacy stream or tar, an .npy or .npz, a raw pickle |
| Root | a dict or OrderedDict of names to tensors (a state dict) | a nested dict ({"model": ..., "epoch": ...}), a list, a non-tensor value, a non-string key |
| dtype | float64, float32, float16, bfloat16, int64, int32, int16, int8, uint8, bool, uint16, uint32, uint64, float8_e4m3fn, float8_e5m2 | anything else: complex, quantized, the fnuz float8s, float8_e8m0fnu, float4 |
| Layout | contiguous by c10's rule, at any storage offset | a non-contiguous view (a transposed weight saved without .contiguous()) |
| Byte order | byteorder record little, or none |
big, or any other content |
| Records | stored uncompressed, CRC-32 holding | compressed, or a CRC-32 that does not hold |
| Sharing | views of one storage that do not overlap; exact aliases | overlapping views that are not exact aliases |
| Tensor state | a Parameter (written as a tensor) | the conj or neg bit (a metadata argument), Python attributes, no storage (a meta tensor) |
| Names | valid UTF-8 | not UTF-8, __metadata__, an alias named format |
Notes on the rules, with their sources:
- The safetensors format is from the
README
and the serializer in
safetensors/src/tensor.rsat v0.8.0: an 8-byte little-endian header length, a JSON header that starts with{and may be padded with spaces,__metadata__as a string-to-string map,data_offsetsrelative to the data buffer, a buffer with no holes, little-endian, row-major. The output follows the serializer (prepare): tensors sorted by dtype alignment, largest first, then by name; the header padded to a multiple of 8 bytes;__metadata__first. The header holds"format": "pt", asconvert.pyand transformers'save_pretrainedwrite it. The output is byte-stable: one input always gives the same bytes. - The dtype pairs are safetensors' own (
parse_dtype_strinbindings/python/src/lib.rs,_TYPESintorch.py). - Which dtypes torch writes through
_rebuild_tensor_v2over a typed storage and which through_rebuild_tensor_v3over an untyped one is_reduce_ex_internalintorch/_tensor.pywith_new_dtypesand_dtype_to_storage_type_mapintorch/storage.py, at v2.14.1. The storage offset counts elements of the tensor's dtype in both (_rebuild_tensor,_rebuild_tensor_v3intorch/_utils.py). torch.save names the untyped storage classtorch.storage.UntypedStorage, whichsocair-wo/1accepts since #233, so a real checkpoint holding a uint16, uint32, uint64, float8_e4m3fn, or float8_e5m2 tensor PASSes the Pickle row and is converted: checkpoints written by torch 2.2.2 and 2.14.1 converted and compared equal with torch.load, tensor by tensor (false-positive-baseline.md). - Contiguity is c10's
_compute_contiguous(c10/core/Contiguity.h): an empty tensor is contiguous, and a dimension of size 1 may have any stride. - The metadata argument holds torch's conj and neg bits, and torch sets a bit
for each key the dict holds whatever its value (
setTensorMetadataintorch/csrc/jit/serialization/pickler_helper.h). The bytes of such a tensor are not its values, so it is refused. - A checkpoint with no
byteorderrecord is read as little-endian, astorch.loaddoes by default (_loadintorch/serialization.py, withconfig.load.endiannessunset). The record says which it was. - torch.save writes records uncompressed (
writeRecord'scompress = falseincaffe2/serialize/inline_container.h), and PyTorch builds miniz withMINIZ_DISABLE_ZIP_READER_CRC32_CHECKS(cmake/Dependencies.cmake), sotorch.loadreads a record whose CRC-32 does not hold while other zip readers stop. A checkpoint saved withcompute_crc32off has no CRC-32 to hold and is refused for the same reason. - Sharing follows safetensors' own tools.
save_filerefuses tensors that share memory but writes views of one storage that do not overlap (_filter_shared_not_shared).save_modelandconvert.pywrite one tensor of a shared group and record each other name in__metadata__asname: kept name(_remove_duplicate_names, which keeps the first name in sorted order). transformers clones disjoint views and drops tied duplicates (remove_tied_weights_from_state_dict). The converter writes disjoint views each as its own tensor and an exact alias (the same bytes, dtype, and shape, such as tied embeddings) once, under the first name in byte order, withname: kept namein__metadata__. It refuses overlapping views that are not exact aliases, wheresave_modelwould record a partial view as an alias of a whole tensor whose values differ from it. So the output's data section is never larger than the input's storages.
The input gate
Conversion runs only on an input that clears all of these:
- Its attestation verifies against the store's trust policy, through the
gate's own check (
Store.Assess), aspromotechecks it. - No row is FAIL or LEAD, the Pickle row's or any other's.
- The Pickle opcode scan row is PASS. The converter reads the tensors from the grammar's model, which only a PASS shows complete.
- The input's bytes, copied into the store's work area and hashed as they are copied, hash to the attested subject. The converter reads only that copy, so the bytes converted are the bytes that were scanned, whatever happens to the original afterwards.
- The converter re-runs the Pickle check on the copy, with no reviewed classes, and it must PASS again.
NOT_TESTED rows on other checks do not block it. A pickle checkpoint's Format and structure row is always NOT_TESTED (Socair parses no pickle structure), so requiring every row to pass would require a signed acceptance, and an acceptance accepts the pickle's gaps for promotion. The conversion authorizes nothing: the record names the input's NOT_TESTED rows, and the output faces its own scan and gate.
What a conversion writes
incoming/<output sha256>/<name>.safetensors: the output, named after the attested file name with its extension replaced.incoming/<output sha256>/derivation.dsse.json: the signed record.- A
convertentry in the activity log:okwith the output's hash, orrefusedwith the input's hash and the reason.
The output is written to the store's .tmp/ area and verified before it is
moved into staging: Socair's safetensors parser must find its layout sound,
and every tensor's bytes in the output must hash to the bytes read from its
record through the zip reader, a second path through the input. A refusal or
a difference leaves nothing in staging. The copy and the output each check
for room first.
The derivation record
A record is an in-toto Statement v1
(statement.md)
with predicate type https://socair.ai/derivation/v1, in a DSSE envelope
signed with Ed25519, the machinery attestations and acceptances use. The
signing key must be in the store's trust policy, so the record verifies
against the store's own keys (derivation.Verify).
Its subject is the output. The predicate:
| Field | Holds |
|---|---|
converter |
name (socair airlock convert), version (the Socair build), rules (socair-convert/1), grammar (socair-wo/1) |
input |
name, digest.sha256, format (torch.save zip), byte_order and how it was established, and attestation: the verified attestation's document_id, document_hash, signer_key_id, state, check_set_version, pickle_row (PASS), and not_tested (the input's NOT_TESTED rows) |
output |
name, digest.sha256 (the subject's), format (safetensors), size_bytes |
converted_at |
RFC 3339 |
tensors |
every input tensor, by name: dtype (safetensors), source_dtype (torch), shape, source (record, offset_bytes), size_bytes, sha256 of its bytes, data_offsets in the output, and alias_of for an exact alias |
metadata |
the output's __metadata__ |
not_carried |
what the output does not carry: tensor types and flags (Parameter, requires_grad, devices), an OrderedDict's attributes (a state dict's _metadata), memory sharing, and storage bytes no tensor views |
does_not_authorize |
fixed wording: the record binds the output's bytes to the input's attested bytes and does not attest the output |
Each tensor's sha256 is of its bytes, which are the same in the input
record and in the output. A reviewer with the input can check each one
without Socair: read size_bytes from source.record at
source.offset_bytes and hash them.
A derivation record is not a SLSA provenance statement
(https://slsa.dev/provenance/v1). SLSA's builder.id is
meant
to determine the SLSA Build level and to resolve to documentation claiming
one, a claim about a build platform that an operator's airlock does not
make, and SLSA has no place for a tensor map.
What it does not establish
- That the output is safe to load: it is scanned as a new artifact.
- That the input was safe: only the attestation's rows say what was checked.
- That the output behaves like the input in a framework that relies on
memory sharing,
requires_grad, or a state dict's_metadata; those are named innot_carried. - Anything about a model directory.
converttakes one checkpoint file and that file's own attestation, and refuses a directory's attestation: pull the file, scan and sign it, and convert it.