Sawtooth Supply Chain Transaction Family Specification

Overview

The Sawtooth Supply Chain transaction family allows users to track goods as they move through a supply chain. Records for goods include a history of ownership and custodianship, as well as histories for a variety of properties such as temperature and location. These properties are managed through a user-specifiable system of record types.

State

All Supply Chain objects are serialized using Protocol Buffers before being stored in state. These objects include: Agents, Properties (accompanied by their auxiliary PropertyPage objects), Proposals, Records, and RecordTypes. As described in the Addressing section below, these objects are stored in separate sub-namespaces under the Supply Chain namespace. To handle hash collisions, all objects are stored in lists within protobuf “Container” objects.

Records

Records represent the goods being tracked by Supply Chain. Almost every transaction references some Record.

A Record contains a unique identifier, the name of a RecordType, and lists containing the history of its owners and custodians. It also contains a final flag indicating whether further updates can be made to the Record and its Properties. If this flag is set to true, then no further updates can be made to the Record, including changing its final flag.

message Record {
    message AssociatedAgent {
        // The Agent's public key
        string agent_id = 1;

        // Approximately when this agent was associated, as a Unix UTC timestamp
        uint64 timestamp = 2;
    }

    // The unique user-defined natural key which identifies the
    // object in the real world (for example, a serial number)
    string identifier = 1;

    string record_type = 2;

    // Ordered oldest to newest by timestamp
    repeated AssociatedAgent owners = 3;
    repeated AssociatedAgent custodians = 4;

    // Flag indicating whether the Record can be updated. If it is set
    // to true, then the record has been finalized and no further
    // changes can be made to it or its Properties.
    bool final = 5;
}

Note that while information about a Record’s owners and custodians are included in the object, information about its Properties are stored separately (see the Properties section below).

Records whose addresses collide are stored in a list alphabetically by identifier.

message RecordContainer {
    repeated Record entries = 1;
}

Properties

Historical data pertaining to a particular data field of a tracked object are stored as Properties, represented as a list of values accompanied by a timestamp and a reporter identifier.

The whole history of updates to Record data is stored in current state because this allows for more flexibility in writing transaction rules. For example, in a fish track-and-trade system, there might be a rule that no fish can be exchanged whose temperature has gone above 40 degrees. This means, however, that it would be impractical to store all of a Record’s data at one address, since adding a single update would require reading the entire history of each of the Record’s Properties out of state, adding the update, then writing it all back.

To solve this problem, Properties are stored in their own namespace derived from their name and associated Record. Since some Properties may have thousands of updates, four characters are reserved at the end of that namespace in order to paginate a Property’s history. The Property itself (along with name, Record identifier, authorized reporters, and paging information) is stored at the namespace ending in 0000. The namespaces ending in 0001 to ffff will each store a PropertyPage containing up to 256 reported values (which include timestamps and their reporter’s identity). Any Transaction updating the value of a Property first reads out the PropertyContainer object at 0000 and then reads out the appropriate PropertyPageContainer before adding the update and writing the new PropertyPageContainer back to state.

The Transaction Processor treats these pages as a ring buffer, so that when page ffff is filled, the next update will erase the entries at page 0001 and be stored there, and subsequent page-filling will continue to overwrite the next oldest page. This ensures no Property ever runs out of space for new updates. Under this scheme, 16^2 * (16^4 - 1) = 16776960 entries can be stored before older updates are overwritten.

Updates to Properties can have one of the following protobuf types: bytes, string, sint64, float, or Location (see the section on RecordTypes below). The type of an update is indicated by a tag belonging to the PropertySchema object.

message Property {
    message Reporter {
        // The public key of the Agent authorized to report updates.
        string public_key = 1;

        // A flag indicating whether the reporter is authorized to
        // send updates. When a reporter is added, this is set to
        // true, and a `RevokeReporter` transaction sets it to false.
        bool authorized = 2;

        // An update must be stored with some way of identifying which
        // Agent sent it. Storing a full public key for each update would
        // be wasteful, so instead Reporters are identified by their index
        // in the `reporters` field.
        uint32 index = 3;
    }

    // The name of the Property, e.g. "temperature". This must be unique
    // among Properties.
    string name = 1;

    // The natural key of the Property's associated Record.
    string record_id = 2;

    // The Property's type (int, string, etc.)
    PropertySchema.DataType data_type = 3;

    // The Reporters authorized to send updates, sorted by index. New
    // Reporters should be given an index equal to the number of
    // Reporters already authorized.
    repeated Reporter reporters = 4;

    // The page to which new updates are added. This number represents
    // the last 4 hex characters of the page's address. Consequently,
    // it should not exceed 16^4 = 65536.
    uint32 current_page = 5;

    // A flag indicating whether the first 16^4 pages have been filled.
    // This is used to calculate the last four hex characters of the
    // address of the page containing the earliest updates. When it is
    // false, the earliest page's address will end in "0001". When it is
    // true, the earliest page's address will be one more than the
    // current_page, or "0001" if the current_page is "ffff".
    bool wrapped = 6;
}

message PropertyPage {
    message ReportedValue {
        // The index of the reporter id in reporters field
        uint32 reporter_index = 1;
        // Approximately when this value was reported, as a Unix UTC timestamp
        uint64 timestamp = 2;

        // The type-specific value of the update. Only one of these
        // fields should be used, and it should match the type
        // specified for this Property in the RecordType.
        bytes bytes_value = 11;
        string string_value = 12;
        sint64 int_value = 13;
        float float_value = 14;
        Location location_value = 15;
    }

    // The name of the page's associated Property and the record_id of
    // its associated Record. These are required to distinguish pages
    // with colliding addresses.
    string name = 1;
    string record_id = 2;

    // ReportedValues are sorted first by timestamp, then by reporter_index.
    repeated ReportedValue reported_values = 4;
}

Properties and PropertyPages whose addresses collide are stored in lists alphabetized by Property name.

message PropertyContainer {
    repeated Property entries = 1;
}

message PropertyPageContainer {
    repeated PropertyPage entries = 1;
}

Record Types

In order to validate incoming tracking data, Records are assigned a RecordType at creation. A RecordType is a user-defined list of PropertySchemas, each of which has a name and data type. PropertySchemas may be designated as required. A required Property must be initialized with a value at the time of a Record’s creation. For example, a Fish type might list species as required, but not temperature, since temperature wouldn’t be known until measurements were taken. Properties not specified at Record creation are initialized as empty lists.

message PropertySchema {
    enum DataType {
        BYTES = 0;
        STRING = 1;
        INT = 2;
        FLOAT = 3;
        LOCATION = 4;
    }

    // The name of the property, e.g. "temperature"
    string name = 1;

    // The Property's type (int, string, etc.)
    DataType data_type = 2;

    // A flag indicating whether initial values must be provided for the
    // Property when a Record is created.
    bool required = 3;
}


message RecordType {
    // A unique human-readable designation for the RecordType
    string name = 1;

    repeated PropertySchema properties = 2;
}

Each Record will have exactly the Properties listed in its type. New Records cannot be created without a type; consequently, a type-creation transaction must be executed before any Records can be created.

RecordTypes whose addresses collide are stored in a list alphabetized by name.

message RecordTypeContainer {
    repeated RecordType entries = 1;
}

Because it is expected to be used for many RecordTypes, a dedicated Location protobuf message is used, the values of which are latitude and longitude.

message Location {
      // Coordinates are expected to be in millionths of a degree
      sint64 latitude = 1;
      sint64 longitude = 2;
}

Agents

Agents are entities that can send transactions affecting Records. This could include not only humans and companies that act as owners and custodians of objects being tracked, but also autonomous sensors sending transactions that update Records’ data. All Agents must be created (registered on-chain) before interacting with Records.

message Agent {
    // The Agent's public key. This must be unique.
    string public_key = 1;

    // A human-readable name identifying the Agent.
    string name = 2;

    // Approximately when the Agent was registered, as a Unix UTC timestamp
    uint64 timestamp = 3;
}

Agents whose keys have the same hash are stored in a list alphabetized by public key.

message AgentContainer {
    repeated Agent entries = 1;
}

Proposals

A Proposal is an offer from the owner or custodian of a Record to authorize another Agent as an owner, custodian, or reporter for that Record. Proposals are tagged as being for transfer of ownership, transfer of custodianship, or authorization of a reporter for some Properties. Proposals are also tagged as being open, accepted, rejected, or canceled. There cannot be more than one open Proposal for a specified role for each combination of Record, receiving Agent, and issuing Agent.

message Proposal {
    enum Role {
        OWNER = 1;
        CUSTODIAN = 2;
        REPORTER = 3;
    }

    enum Status {
        OPEN = 1;
        ACCEPTED = 2;
        REJECTED = 3;
        CANCELED = 4;
    }

    // The id of the Record with which this Proposal deals
    string record_id = 1;

    // Approximately when this proposal was created, as a Unix UTC timestamp
    uint64 timestamp = 2;

    // The public key of the Agent that created the Proposal
    string issuing_agent = 3;

    // The public key of the Agent to which the Proposal is addressed
    string receiving_agent = 4;

    // Whether the Proposal is for transfer of ownership or
    // custodianship or reporter authorization
    Role role = 5;

    // The names of properties for which the reporter is being authorized
    // (empty for owner or custodian transfers)
    repeated string properties = 6;

    // Whether the Proposal is open, accepted, rejected, or canceled.
    // For a given Record and receiving Agent, there can be only one
    // open Proposal at a time for each role.
    Status status = 7;

    // human-readable terms of transfer
    string terms = 8;
}

Proposals with the same address are stored in a list sorted alphabetically first by record_id, then by receiving_agent, then by timestamp (earliest to latest).

message ProposalContainer {
    repeated Proposal entries = 1;
}

Addressing

Supply Chain objects are stored under the namespace obtained by taking the first six characters of the SHA-512 hash of the string supply_chain:

>>> def get_hash(string):
...     return hashlib.sha512(string.encode('utf-8')).hexdigest()
...
>>> get_hash('supply_chain')[:6]
'3400de'

After its namespace prefix, the next two characters of a Supply Chain object’s address are a string based on the object’s type:

  • Agent: ae

  • Property / PropertyPage: ea

  • Proposal: aa

  • Record: ec

  • Record Type: ee

The remaining 62 characters of an object’s address are determined by its type:

  • Agent: the first 62 characters of the hash of its public key.

  • Property: the concatenation of the following:

    • The first 36 characters of the hash of the identifier of its associated Record plus the first 22 characters of the hash of its Property name.

    • The string 0000.

  • PropertyPage: the address of the page to which updates are to be written is the concatenation of the following:

    • The first 36 characters of the hash of the identifier of its associated Record.

    • The first 22 characters of the hash of its Property name.

    • The hex representation of the current_page of its associated Property left-padded to length 4 with 0s.

  • Proposal: the concatenation of the following:

    • The first 36 characters of the hash of the identifier of its associated Record.

    • The first 22 characters of its receiving_agent.

    • The first 4 characters of the hash of its timestamp.

  • Record: the first 62 characters of the hash of its identifier.

  • Record Type: the first 62 characters of the hash of the name of the type.

For example, if fish-456 is a Record with a temperature Property and a current_page of 28, the address for that PropertyPage is:

>>> get_hash('supply_chain')[:6] + 'ea'  + get_hash('fish-456')[:36] + get_hash('temperature')[:22] + hex(28)[2:].zfill(4)
'3400deea840d00edc7507ed05cfb86938e3624ada6c7f08bfeb8fd09b963f81f9d001c'

Transactions

Transaction Payload

All Supply Chain transactions are wrapped in a tagged payload object to allow for the transaction to be dispatched to appropriate handling logic.

message SCPayload {
    enum Action {
        CREATE_AGENT = 1;
        CREATE_RECORD = 2;
        FINALIZE_RECORD = 3;
        CREATE_RECORD_TYPE = 4;
        UPDATE_PROPERTIES = 5;
        CREATE_PROPOSAL = 6;
        ANSWER_PROPOSAL = 7;
        REVOKE_REPORTER = 8;
    }

    Action action = 1;

    // Approximately when transaction was submitted, as a Unix UTC timestamp
    uint64 timestamp = 2;

    CreateAgentAction create_agent = 3;
    CreateRecordAction create_record = 4;
    FinalizeRecordAction finalize_record = 5;
    CreateRecordTypeAction create_record_type = 6;
    UpdatePropertiesAction update_properties = 7;
    CreateProposalAction create_proposal = 8;
    AnswerProposalAction answer_proposal = 9;
    RevokeReporterAction revoke_reporter = 10;
}

Any transaction is invalid if its timestamp is greater than the validator’s system time.

Create Agent

Create an Agent that can interact with Records. The signer_pubkey in the transaction header is used as the Agent’s public key.

message CreateAgentAction {
   // The human-readable name of the Agent, not necessarily unique
   string name = 1;
}

A CreateAgent transaction is invalid if there is already an Agent with the signer’s public key or if the name is the empty string.

Create Record

When an Agent creates a Record, the Record is initialized with that Agent as both owner and custodian. Any Properties required of the Record by its RecordType must have initial values provided.

message PropertyValue {
    // The name of the property being set
    string name = 1;
    PropertySchema.DataType data_type = 2;

    // The type-specific value to initialize or update a Property. Only
    // one of these fields should be used, and it should match the type
    // specified for this Property in the RecordType.
    bytes bytes_value = 11;
    string string_value = 12;
    sint64 int_value = 13;
    float float_value = 14;
    Location location_value = 15;
}

message CreateRecordAction {
    // The natural key of the Record
    string record_id = 1;

    // The name of the RecordType this Record belongs to
    string record_type = 2;

    repeated PropertyValue properties = 3;
}

A CreateRecord transaction is invalid if one of the following conditions occurs:

  • The signer is not registered as an Agent.

  • The identifier is the empty string.

  • The identifier belongs to an existing Record.

  • A valid RecordType is not specified.

  • Initial values are not provided for all of the Properties specified as required by the RecordType.

  • Initial values of the wrong type are provided.

Finalize Record

A FinalizeRecord Transaction sets a Record’s final flag to true. A finalized Record and its Properties cannot be updated. A Record cannot be finalized except by its owner, and cannot be finalized if the owner and custodian are not the same.

message FinalizeRecordAction {
    string record_id = 1;
}

A FinalizeRecord transaction is invalid if one of the following conditions occurs:

  • The Record it targets does not exist.

  • The Record it targets is already final.

  • The signer is not both the Record’s owner and custodian.

Create Record Type

The payload of the Transaction that creates RecordTypes is the same as the RecordType object itself: it has a name and a list of Properties.

message CreateRecordTypeAction {
    string name = 1;

    repeated PropertySchema properties = 2;
}

A CreateRecordType transaction is invalid if one of the following conditions occurs:

  • The signer is not registered as an Agent.

  • Its list of Properties is empty.

  • The name of the RecordType is the empty string.

  • A RecordType with its name already exists.

Update Properties

An UpdateProperties transaction contains a record_id and a list of PropertyValues (see CreateRecord above). It can only be (validly) sent by an Agent authorized to report on the Property.

message UpdatePropertiesAction {
    // The natural key of the Record
    string record_id = 1;

    repeated PropertyValue properties = 2;
}

An UpdateProperties transaction is invalid if one of the following conditions occurs:

  • The Record does not exist.

  • The Record is final.

  • Its signer is not authorized to report on that Record.

  • None of the provided PropertyValues match the types specified in the Record’s RecordType.

Create Proposal

A CreateProposal transaction creates an open Proposal concerning some Record from the signer to the receiving Agent. This Proposal can be for transfer of ownership, transfer of custodianship, or authorization to report. If it is a reporter authorization Proposal, a nonempty list of Property names must be included.

message CreateProposalPayload {
    enum Role {
        OWNER = 1;
        CUSTODIAN = 2;
        REPORTER = 3;
    }

    string record_id = 1;

    // The public key of the Agent to whom the Proposal is sent
    // (must be different from the Agent sending the Proposal).
    string receiving_agent = 3;

    repeated string properties = 4;

    Role role = 5;
}

A CreateProposal transaction is invalid if one of the following conditions occurs:

  • The signer is not the owner and the Proposal is for transfer of ownership or reporter authorization.

  • The signer is not the custodian and the Proposal is for transfer of custodianship.

  • The receiving Agent is not registered (the signer must be registered as well, but this is implied by the previous two conditions).

  • There is already an open Proposal for the Record and receiving Agent for the specified role.

  • The Record is final.

  • The Proposal is for reporter authorization and the list of Property names is empty.

Answer Proposal

An Agent who is the receiving Agent for a Proposal for some Record can accept or reject that Proposal, marking the Proposal’s status as accepted or rejected. The Proposal’s issuing_agent cannot accept or reject it, but can cancel it. This will mark the Proposal’s status as canceled rather than rejected.

message AnswerProposalPayload {
    enum Role {
        OWNER = 1;
        CUSTODIAN = 2;
        REPORTER = 3;
    }

    enum Response {
        ACCEPT = 1;
        REJECT = 2;
        CANCEL = 3;
    }

    string record_id = 1;
    string receiving_agent = 2;
    Role role = 3;
    Response response = 4;
}

Proposals can conflict, in the sense that a Record’s owner might have opened ownership transfer Proposals with several Agents at once. These Proposals will not be closed if one of them is accepted. Instead, an accept answer will check to verify that the issuing Agent is still the owner or custodian of the Record.

An AnswerProposal transaction is invalid if one of the following conditions occurs:

  • There is no Proposal for that receiving agent, record, and role.

  • The signer is not the receiving or issuing Agent of the Proposal.

  • The signer is the receiving Agent and answers cancel.

  • The signer is the issuing Agent and answers anything other than cancel.

  • The response is accept, but the issuing Agent is no longer the owner or custodian (as appropriate to the role) of the Record.

Revoke Reporter

The owner of a Record can send a RevokeReporter transaction to remove a reporter’s authorization to report on one or more Properties for that Record. This creates a Proposal which is immediately closed and marked as accepted.

message RevokeReporterPayload {
    string record_id = 1;
    string reporter_id = 2;

    // the Properties for which the reporter's authorization is revoked
    repeated string properties = 3;
}

A RevokeReporter transaction is invalid if one of the following conditions occurs:

  • The Record does not exist.

  • The Record is final.

  • The signer is not the Record’s owner.

  • The reporter whose authorization is to be revoked is not an authorized reporter for the Record.